From a3df05e067d251bf0dbaf8ff2dd109cd11cd5a71 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 20 Jun 2026 18:19:57 +0000 Subject: [PATCH] =?UTF-8?q?fix(arguments):=20route=20"=D7=97=D7=A9=D7=91?= =?UTF-8?q?=20=D7=98=D7=99=D7=A2=D7=95=D7=A0=D7=99=D7=9D"=20through=20the?= =?UTF-8?q?=20legal-analyst=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /aggregate-arguments endpoint ran an in-container BackgroundTask that called claude_session (the local `claude` CLI) — which does not exist in the FastAPI container. The button silently produced nothing, and on `force` it destructively DELETEd existing arguments *before* the doomed LLM call. Replace the inline task with the established delegation pattern used by "חלץ עובדות שמאיות" (extract-appraiser-facts): a cheap in-container DB pre-check (no_claims / exists), then a Paperclip wakeup of the company's legal-analyst, which runs mcp__legal-ai__aggregate_claims_to_arguments locally (where the CLI lives) and reports back. `force` now runs locally too, so delete+recompute are atomic on the host — no more destructive failure. Frontend: AggregateArgumentsResult becomes a discriminated union (queued | no_claims | exists | skipped) and the toast is status-accurate instead of the misleading fixed "refresh in a minute". Invariants: G12 (Paperclip touch confined to paperclip_client behind the agent_platform_port), G2 (replaces the broken path, no parallel capability), engineering §6 (explicit statuses, no silent swallow). UI change is logic/toast only (no visual-layout change) — within the Claude-Design-gate bug-fix exemption. Richer inline status panels deferred to the gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cases/legal-arguments-panel.tsx | 27 +++- web-ui/src/lib/api/legal-arguments.ts | 30 ++++- web/agent_platform_port.py | 2 + web/app.py | 94 +++++++++----- web/paperclip_client.py | 118 ++++++++++++++++++ 5 files changed, 231 insertions(+), 40 deletions(-) diff --git a/web-ui/src/components/cases/legal-arguments-panel.tsx b/web-ui/src/components/cases/legal-arguments-panel.tsx index e2ee746..a8aad2b 100644 --- a/web-ui/src/components/cases/legal-arguments-panel.tsx +++ b/web-ui/src/components/cases/legal-arguments-panel.tsx @@ -190,12 +190,27 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) { const handleAggregate = (force: boolean) => { aggregate.mutate(force, { - onSuccess: () => { - toast.success( - force - ? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה." - : "הופעל חישוב טיעונים. רענן בעוד דקה.", - ); + onSuccess: (result) => { + switch (result.status) { + case "queued": + toast.success( + force + ? "נשלח לאנליטיקאי לחישוב מחדש. התוצאה תופיע תוך כמה דקות — רענן את הדף." + : "נשלח לאנליטיקאי. החישוב רץ ברקע; התוצאה תופיע תוך כמה דקות — רענן את הדף.", + ); + break; + case "no_claims": + case "exists": + toast.info(result.message); + break; + case "skipped": + toast.warning( + `לא ניתן להפעיל אוטומטית (${result.reason}). הרץ ידנית מ-Claude Code: mcp__legal-ai__aggregate_claims_to_arguments`, + ); + break; + default: + toast.success("הבקשה נשלחה."); + } }, onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`), }); diff --git a/web-ui/src/lib/api/legal-arguments.ts b/web-ui/src/lib/api/legal-arguments.ts index 50f3813..8b41cfc 100644 --- a/web-ui/src/lib/api/legal-arguments.ts +++ b/web-ui/src/lib/api/legal-arguments.ts @@ -71,12 +71,30 @@ export function useLegalArguments(caseNumber: string | undefined) { }); } -export type AggregateArgumentsResult = { - status: "started" | string; - case_number: string; - force: boolean; - message: string; -}; +/** + * The aggregation runs on the legal-analyst agent (host-side, where the + * `claude` CLI lives) — NOT inline in the FastAPI container. The endpoint + * either delegates via a Paperclip wakeup (`queued`) or short-circuits on a + * cheap in-container DB pre-check (`no_claims` / `exists`). `skipped` means + * no analyst route was available; the chair can run the MCP tool manually. + */ +export type AggregateArgumentsResult = + | { + status: "queued"; + sub_issue_id: string; + analyst_id: string; + main_issue_id: string; + } + | { + status: "no_claims" | "exists"; + total: number; + message: string; + } + | { + status: "skipped"; + reason: "no_api_key" | "no_analyst" | "no_issue" | string; + company_id?: string; + }; export function useAggregateArguments(caseNumber: string | undefined) { const qc = useQueryClient(); diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index be5bbd0..4af9c16 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -56,6 +56,7 @@ from web.paperclip_client import ( restore_project as pc_restore_project, update_project_name as pc_update_project_name, 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_ceo_agent as pc_wake_ceo, wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold, wake_curator_for_final as pc_wake_curator_for_final, @@ -99,6 +100,7 @@ __all__ = [ "pc_wake_curator_for_final", "pc_wake_for_precedent_extraction", "pc_wake_analyst_for_appraiser_facts", + "pc_wake_analyst_for_argument_aggregation", # comments / interactions "pc_post_comment", "pc_get_issue_comments", diff --git a/web/app.py b/web/app.py index 8f44d2a..2d45b6f 100644 --- a/web/app.py +++ b/web/app.py @@ -75,6 +75,7 @@ from web.agent_platform_port import ( pc_respond_to_interaction, pc_restore_project, pc_wake_analyst_for_appraiser_facts, + pc_wake_analyst_for_argument_aggregation, rename_case_project, pc_wake_ceo, pc_wake_ceo_for_feedback_fold, @@ -2454,41 +2455,78 @@ async def api_get_claims(case_number: str): # the FastAPI container it short-circuits with status="llm_unavailable". @app.post("/api/cases/{case_number}/aggregate-arguments") -async def api_aggregate_arguments( - case_number: str, - background_tasks: BackgroundTasks, - force: bool = False, -): - """Aggregate raw claims into distinct legal arguments via Claude. +async def api_aggregate_arguments(case_number: str, force: bool = False): + """Queue claim→argument aggregation by waking the legal-analyst agent. - Runs as a BackgroundTask because the LLM pass can take 30-90 seconds. + The aggregation itself calls `claude_session.query_json()`, which shells + out to the local `claude` CLI — present on the agent host, **absent in + this FastAPI container**. Running it inline as a BackgroundTask (the old + behaviour) silently produced nothing, and on `force` it destructively + deleted the existing arguments *before* the doomed LLM call. So we + delegate to the analyst exactly like `extract-appraiser-facts`: create a + child Paperclip issue, assign it to the company's analyst, and trigger a + wakeup. The analyst runs the MCP tool locally and posts results. + + Cheap in-container pre-checks (DB only, no LLM) short-circuit before any + agent is spun up: + - `no_claims` — there are no raw claims to aggregate yet. + - `exists` — arguments already computed and `force` is False. + + Response shape: + {"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"} + or {"status": "no_claims"|"exists", "total", "message"} + or {"status": "skipped", "reason": "no_api_key"|"no_analyst"|"no_issue"} """ case = await db.get_case_by_number(case_number) if not case: raise HTTPException(404, f"תיק {case_number} לא נמצא") - async def _run() -> None: - try: - from legal_mcp.services import argument_aggregator - result = await argument_aggregator.aggregate_claims_to_arguments( - UUID(case["id"]), force=force, - ) - logger.info( - "aggregate_arguments[%s] finished: %s", - case_number, result, - ) - except Exception as e: # noqa: BLE001 - logger.exception( - "aggregate_arguments[%s] failed: %s", case_number, e, - ) + case_id = UUID(case["id"]) + pool = await db.get_pool() + async with pool.acquire() as conn: + claim_count = await conn.fetchval( + "SELECT COUNT(*) FROM claims WHERE case_id = $1", case_id, + ) + existing_args = await conn.fetchval( + "SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1", case_id, + ) - background_tasks.add_task(_run) - return { - "status": "started", - "case_number": case_number, - "force": force, - "message": "Aggregation started in background. Poll /legal-arguments for results.", - } + if not claim_count: + return { + "status": "no_claims", + "total": 0, + "message": ( + "אין טענות גולמיות בתיק. הרץ קודם חילוץ טענות (extract_claims) " + "ואז חשב טיעונים." + ), + } + if existing_args and not force: + return { + "status": "exists", + "total": existing_args, + "message": ( + f"כבר קיימים {existing_args} טיעונים מאוגדים. השתמש בכפתור " + "החישוב-מחדש כדי לחשב מחדש (מוחק ובונה מחדש)." + ), + } + + # Route to the analyst of the correct company by case-number prefix. + prefix = case_number[:1] + company_id = ( + PAPERCLIP_COMPANIES["licensing"] if prefix == "1" + else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9") + else "" + ) + + try: + result = await pc_wake_analyst_for_argument_aggregation( + case_number, company_id=company_id, force=force, + ) + except Exception as e: + logger.exception("analyst wakeup failed for argument aggregation %s", case_number) + raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}") + + return result @app.get("/api/cases/{case_number}/legal-arguments") diff --git a/web/paperclip_client.py b/web/paperclip_client.py index a2b9ec1..0186318 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -1371,3 +1371,121 @@ async def wake_analyst_for_appraiser_facts( "analyst_id": analyst_id, "main_issue_id": main_issue_id, } + + +async def wake_analyst_for_argument_aggregation( + case_number: str, + company_id: str, + force: bool = False, +) -> dict: + """Wake the legal-analyst to aggregate raw claims into legal arguments. + + Triggered by the chair clicking "חשב טיעונים" in the case page. The + FastAPI container cannot run `aggregate_claims_to_arguments` directly — + the aggregator calls `claude_session.query_json()`, which only works + where the local `claude` CLI is present (the MCP server / agent runner + on the host), **not in this container**. So instead of an in-container + BackgroundTask (which fails silently and, on `force`, destructively + deletes the existing arguments before the doomed LLM call), we create a + child issue under the case's main Paperclip issue, assign it to the + analyst of the correct company, and trigger a wakeup. The analyst's + HEARTBEAT picks up the issue, runs the MCP tool locally, and reports + back via a comment. + + Mirrors ``wake_analyst_for_appraiser_facts`` — same delegation shape. + + Returns a dict shaped for the FastAPI endpoint to serialize as-is: + {"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"} + or {"status": "skipped", "reason": "..."} for non-fatal early outs. + """ + if not PAPERCLIP_BOARD_API_KEY: + logger.warning( + "PAPERCLIP_BOARD_API_KEY not set — cannot queue analyst wakeup " + "for argument aggregation on %s", + case_number, + ) + return {"status": "skipped", "reason": "no_api_key"} + + analyst_id = ANALYST_AGENTS.get(company_id) + if not analyst_id: + logger.info("No analyst configured for company %s — skipping", company_id) + return {"status": "skipped", "reason": "no_analyst", "company_id": company_id} + + issues = await get_case_issues(case_number) + if not issues: + logger.warning( + "No Paperclip issues found for case %s — cannot queue analyst", case_number, + ) + return {"status": "skipped", "reason": "no_issue"} + + main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0] + main_issue_id = main_issue["id"] + + force_clause = ", force=True" if force else "" + rerun_note = ( + "זהו חישוב-מחדש (force) — הטיעונים הקיימים יימחקו ויחושבו מחדש.\n\n" + if force else "" + ) + description = ( + f"חיים ביקש חישוב טיעונים משפטיים בתיק {case_number}.\n\n" + f"{rerun_note}" + f"הרץ `mcp__legal-ai__aggregate_claims_to_arguments(case_number=\"{case_number}\"{force_clause})` " + f"וכתוב comment בעברית עם תוצאת האיגוד — כמה טיעונים מובחנים נוצרו לכל צד " + f"(עוררים / משיבים / ועדה / מבקשי-היתר). אם אין טענות גולמיות בתיק, דווח " + f"ב-comment שצריך להריץ קודם חילוץ טענות (`extract_claims`) וסגור את ה-issue כ-blocked." + ) + child_resp = await pc_request( + "POST", + f"/api/issues/{main_issue_id}/children", + json={ + "title": f"[ערר {case_number}] חישוב טיעונים משפטיים", + "description": description, + "status": "in_progress", + # Paperclip ISSUE_PRIORITIES = critical|high|medium|low — "normal" + # is NOT a valid enum value (Zod 400 → surfaces as 500). + "priority": "medium", + "assigneeAgentId": analyst_id, + }, + raise_on_error=True, + ) + sub_issue = child_resp.json() + sub_issue_id = sub_issue["id"] + + # Tag plugin_state so the case page surfaces this sub-issue too. + try: + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + await _link_case_to_issue(conn, sub_issue_id, case_number) + finally: + await conn.close() + except Exception as e: + logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e) + + wake_resp = await pc_request( + "POST", + f"/api/agents/{analyst_id}/wakeup", + json={ + "source": "on_demand", + "triggerDetail": "manual", + "reason": f"aggregate_arguments_{case_number}", + # "assignment" is the generic mutation the HEARTBEAT recognises; + # task intent lives in the child-issue description, not the payload. + "payload": { + "issueId": sub_issue_id, + "mutation": "assignment", + "caseNumber": case_number, + }, + }, + raise_on_error=True, + ) + logger.info( + "Analyst wakeup for argument aggregation on case %s: sub_issue=%s " + "analyst=%s force=%s wake=%s", + case_number, sub_issue_id, analyst_id, force, wake_resp.status_code, + ) + return { + "status": "queued", + "sub_issue_id": sub_issue_id, + "analyst_id": analyst_id, + "main_issue_id": main_issue_id, + } -- 2.49.1