feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 15s

עד כה ה-"מייצר…" כיסה רק את שנייה-ה-POST; אחרי זה לא היה חיווי אם ההפקה
רצה/הצליחה/נכשלה — ריצה שבוטלה נראתה כמו הצלחה. עכשיו צ'יפ-סטטוס ליד כל
כפתור-הפקה: בתור · רץ+שעון-חי · הושלם+מתי · נכשל+סיבה+"נסה שוב".

**נשמר-שרת (דרישת חיים):** הסטטוס נגזר בשרת מ-issue-הילד של ה-CEO +
ה-heartbeat_run האחרון + קיום-הקובץ — לא מזיכרון-הדף. שעון-הריצה מעוגן
ל-started_at של השרת, כך שעזיבה+חזרה לדף מציגות את המצב והזמן הנכונים,
בלי צורך להישאר בדף.

Backend:
- paperclip_client.get_generation_run_status — קורא issue-ילד + latest run
  מ-Paperclip DB (read-only, מאחורי שער-הפלטפורמה G12); dedup לפי כותרת.
- GET /api/cases/{n}/generate/{action}/status — ממפה ל-idle/queued/running/
  done/failed + output_ready + started_at/finished_at (UTC). run שהסתיים בלי
  קובץ = failed (שלא ייראה כהצלחה).

Frontend:
- useGenerateStatus (poll 5s רק כשפעיל; עוצר בטרמינלי) + GenerateStatusChip
  (שעון-ריצה חי מ-started_at; רכיב חסר-מצב). כפתורי-ההפקה מרעננים את
  שאילתת-הסטטוס ב-onSuccess. שורה 3 (החלטה מלאה, סינכרוני) — בלי צ'יפ.

עיצוב: מוקאפ 29-generate-status-indicator אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint + py_compile ירוקים. (api:types לריענון post-deploy.)
Invariants: G12 (Paperclip רק בשער), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 11:33:41 +00:00
parent 21ad541659
commit b296422d87
6 changed files with 360 additions and 3 deletions

View File

@@ -61,6 +61,7 @@ from web.paperclip_client import (
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
wake_analyst_for_protocol_analysis as pc_wake_analyst_for_protocol_analysis,
get_generation_run_status as pc_get_generation_run_status,
wake_ceo_agent as pc_wake_ceo,
wake_ceo_for_action as pc_wake_ceo_for_action,
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
@@ -108,6 +109,7 @@ __all__ = [
"pc_wake_analyst_for_appraiser_facts",
"pc_wake_analyst_for_argument_aggregation",
"pc_wake_analyst_for_protocol_analysis",
"pc_get_generation_run_status",
# comments / interactions
"pc_post_comment",
"pc_get_issue_comments",

View File

@@ -80,6 +80,7 @@ from web.agent_platform_port import (
pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation,
pc_wake_analyst_for_protocol_analysis,
pc_get_generation_run_status,
rename_case_project,
pc_wake_ceo,
pc_wake_ceo_for_action,
@@ -3249,6 +3250,95 @@ async def api_generate_interim_draft(case_number: str):
return await _wake_ceo_action(case_number, "interim_draft")
_GENERATE_ACTIONS = {"party_claims_summary", "interim_draft"}
# Run statuses (Paperclip heartbeat_runs.status) grouped for state mapping.
_RUN_ACTIVE = {"running", "in_progress", "queued", "pending", "scheduled", "created"}
_RUN_FAILED = {"cancelled", "failed", "error", "timed_out", "lost"}
def _generation_output_ready(case_number: str, action: str) -> bool:
"""Whether the generation artifact already exists on disk (host-side)."""
if action == "party_claims_summary":
from legal_mcp.services import party_claims_summary as pcs
return pcs.summary_file_path(case_number).exists()
# interim_draft → a "טיוטת-ביניים-{case}-*.docx" file in the case exports dir.
export_dir = config.find_case_dir(case_number) / "exports"
if not export_dir.exists():
return False
return any(
f.is_file() and f.suffix.lower() == ".docx" and f.name.startswith("טיוטת-ביניים-")
for f in export_dir.iterdir()
)
@app.get("/api/cases/{case_number}/generate/{action}/status")
async def api_generate_status(case_number: str, action: str):
"""Server-derived status of a "הפקת מסמכים" background generation (#227 ג).
Reconstructs {idle|queued|running|done|failed} from the CEO child issue + its
latest heartbeat_run (via the platform port) plus whether the output artifact
exists — so the UI indicator is correct after navigating away and back; it
never depends on the browser staying on the page. ``started_at``/``finished_at``
are server timestamps (ISO-8601 UTC) so the client renders a correct elapsed
time on return.
"""
if action not in _GENERATE_ACTIONS:
raise HTTPException(400, f"action לא תקין: {action}")
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
output_ready = _generation_output_ready(case_number, action)
try:
run = await pc_get_generation_run_status(case_number, action)
except Exception as e:
logger.warning("generation status lookup failed for %s/%s: %s", case_number, action, e)
run = {"issue_id": None}
run_status = (run.get("run_status") or "").lower()
started_at = run.get("started_at")
active = run_status in _RUN_ACTIVE or (
run.get("issue_status") == "in_progress"
and run_status not in _RUN_FAILED
and run_status not in {"completed", "succeeded", "done"}
and not run.get("finished_at")
)
# Precedence: an in-flight run wins; then a produced artifact; then a failed
# run; else idle. (A produced file reads as "done" even if a later retry
# failed — the artifact exists.)
if active:
state = "running" if started_at else "queued"
elif output_ready:
state = "done"
elif run_status in _RUN_FAILED:
state = "failed"
elif run_status in {"completed", "succeeded", "done"}:
# Ran to completion but produced no artifact → treat as a failure so the
# chair isn't left thinking it worked.
state = "failed"
else:
state = "idle"
detail = ""
if state == "failed":
if run.get("error_code") == "issue_assignee_changed":
detail = "הריצה בוטלה (הוקצתה מחדש)"
else:
detail = run.get("error") or "הריצה נכשלה"
return {
"case_number": case_number,
"action": action,
"state": state,
"started_at": started_at,
"finished_at": run.get("finished_at"),
"output_ready": output_ready,
"detail": detail,
"issue_id": run.get("issue_id"),
}
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file."""

View File

@@ -1384,6 +1384,73 @@ async def wake_ceo_for_action(
}
# Substring of the generation child-issue title per action (see _ceo_action_brief).
GENERATION_TITLE_SUBSTR = {
"party_claims_summary": "סיכום-מנהלים",
"interim_draft": "טיוטת טענות-הצדדים",
}
async def get_generation_run_status(case_number: str, action: str) -> dict:
"""Server-derived run status for a "הפקת מסמכים" generation action (#227 ג).
Reconstructs the state of the most recent generation child issue for
(case, action) from Paperclip — the child issue + its latest heartbeat_run —
so the UI indicator survives navigation/reload (it never depends on the
browser having stayed on the page). Read-only Paperclip DB access, behind the
platform port (G12).
Returns a serializable dict::
{"issue_id", "issue_status", "run_status", "started_at", "finished_at",
"error", "error_code"}
with all timestamps as ISO-8601 UTC strings (or None). ``issue_id`` is None
when no generation child issue exists yet (→ the endpoint reports 'idle').
"""
substr = GENERATION_TITLE_SUBSTR.get(action)
if not substr:
return {"issue_id": None}
def _iso(dt) -> str | None:
return dt.isoformat() if dt is not None else None
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
issue = await conn.fetchrow(
"""SELECT id, status
FROM issues
WHERE title LIKE ('%' || $1 || '%') AND title LIKE ('%' || $2 || '%')
ORDER BY created_at DESC
LIMIT 1""",
case_number, substr,
)
if issue is None:
return {"issue_id": None}
run = await conn.fetchrow(
"""SELECT hr.status, hr.started_at, hr.finished_at, hr.error, hr.error_code
FROM heartbeat_runs hr
JOIN agent_wakeup_requests awr ON hr.wakeup_request_id = awr.id
WHERE awr.payload->>'issueId' = $1
ORDER BY hr.created_at DESC
LIMIT 1""",
str(issue["id"]),
)
finally:
await conn.close()
return {
"issue_id": str(issue["id"]),
"issue_status": issue["status"],
"run_status": run["status"] if run else None,
"started_at": _iso(run["started_at"]) if run else None,
"finished_at": _iso(run["finished_at"]) if run else None,
"error": (run["error"] if run else None) or None,
"error_code": (run["error_code"] if run else None) or None,
}
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
"""Build the (sub-issue title, description) for a staged final-decision task.