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

@@ -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."""