diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index d2cd825..5cb2c0c 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -31,7 +31,9 @@ import { usePartyClaimsSummary, useGeneratePartyClaimsSummary, useGenerateInterimDraft, + useGenerateStatus, } from "@/lib/api/generate"; +import { GenerateStatusChip } from "@/components/cases/generate-status-chip"; import { useCaseFeedback, useCreateFeedback, @@ -133,6 +135,9 @@ export function DraftsPanel({ const { data: summary } = usePartyClaimsSummary(caseNumber); const genSummary = useGeneratePartyClaimsSummary(caseNumber); const genInterim = useGenerateInterimDraft(caseNumber); + // Server-derived background-run status (#227 ג) — survives navigation/reload. + const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary"); + const interimStatus = useGenerateStatus(caseNumber, "interim_draft"); const qc = useQueryClient(); const fileRef = useRef(null); @@ -342,6 +347,11 @@ export function DraftsPanel({ "הפק" )} + {summaryReady && ( <> + )} + + ); +} diff --git a/web-ui/src/lib/api/generate.ts b/web-ui/src/lib/api/generate.ts index a8ec50f..4a651f4 100644 --- a/web-ui/src/lib/api/generate.ts +++ b/web-ui/src/lib/api/generate.ts @@ -29,12 +29,58 @@ export type GenerateTriggerResult = { error?: string; }; +/** The two host-side background generation actions (poll target of the status chip). */ +export type GenerateAction = "party_claims_summary" | "interim_draft"; + export const generateKeys = { all: ["generate"] as const, partyClaimsSummary: (caseNumber: string) => [...generateKeys.all, "party-claims-summary", caseNumber] as const, + status: (caseNumber: string, action: GenerateAction) => + [...generateKeys.all, "status", action, caseNumber] as const, }; +/** + * Server-derived background-run status (#227 ג). Reconstructed on the backend + * from the CEO child issue + its heartbeat_run + the output artifact — so it is + * correct after navigating away and back; it never depends on the browser having + * stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps, + * so the client renders a correct elapsed time on return. + */ +export type GenerateState = "idle" | "queued" | "running" | "done" | "failed"; + +export type GenerateStatus = { + case_number: string; + action: GenerateAction; + state: GenerateState; + started_at: string | null; + finished_at: string | null; + output_ready: boolean; + detail: string; + issue_id: string | null; +}; + +export function useGenerateStatus( + caseNumber: string | undefined, + action: GenerateAction, +) { + return useQuery({ + queryKey: generateKeys.status(caseNumber ?? "", action), + queryFn: ({ signal }) => + apiRequest( + `/api/cases/${caseNumber}/generate/${action}/status`, + { signal }, + ), + enabled: Boolean(caseNumber), + // Poll only while a run is in flight; a terminal state stops the interval. + refetchInterval: (query) => { + const s = query.state.data?.state; + return s === "running" || s === "queued" ? 5_000 : false; + }, + staleTime: 2_000, + }); +} + /* ── Read: party-claims executive summary (poll target for button 1) ── 404 (not generated yet) is a normal "not ready" state, not an error — we coerce it to null so the panel renders the empty/idle state cleanly. */ @@ -70,23 +116,33 @@ export function useGeneratePartyClaimsSummary(caseNumber: string) { { method: "POST" }, ), onSuccess: () => { - // Start polling fresh — the file will appear after the host run completes. + // Start polling fresh — the file will appear after the host run completes, + // and the status chip should pick up the new queued/running run. qc.invalidateQueries({ queryKey: generateKeys.partyClaimsSummary(caseNumber), }); + qc.invalidateQueries({ + queryKey: generateKeys.status(caseNumber, "party_claims_summary"), + }); }, }); } /* ── Trigger: interim ("party-claims") partial draft (button 2) ── */ export function useGenerateInterimDraft(caseNumber: string) { + const qc = useQueryClient(); return useMutation({ mutationFn: () => apiRequest( `/api/cases/${caseNumber}/generate/interim-draft`, { method: "POST" }, ), - // The result lands in the exports list (טיוטת-ביניים-*.docx); useExports - // already polls on a 5s interval, so no extra invalidation is needed here. + // The result lands in the exports list (טיוטת-ביניים-*.docx, polled by + // useExports); re-arm the status chip so it shows the new run immediately. + onSuccess: () => { + qc.invalidateQueries({ + queryKey: generateKeys.status(caseNumber, "interim_draft"), + }); + }, }); } diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index de6a7b9..2579545 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -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", diff --git a/web/app.py b/web/app.py index d502bde..a98a95d 100644 --- a/web/app.py +++ b/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.""" diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 55cef15..d046033 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -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.