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:
@@ -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",
|
||||
|
||||
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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user