feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
עד כה ה-"מייצר…" כיסה רק את שנייה-ה-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:
90
web/app.py
90
web/app.py
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user