fix(arguments): route "חשב טיעונים" through the legal-analyst agent
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) <noreply@anthropic.com>
This commit is contained in:
@@ -190,12 +190,27 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
|||||||
|
|
||||||
const handleAggregate = (force: boolean) => {
|
const handleAggregate = (force: boolean) => {
|
||||||
aggregate.mutate(force, {
|
aggregate.mutate(force, {
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
toast.success(
|
switch (result.status) {
|
||||||
force
|
case "queued":
|
||||||
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
|
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}`),
|
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,12 +71,30 @@ export function useLegalArguments(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AggregateArgumentsResult = {
|
/**
|
||||||
status: "started" | string;
|
* The aggregation runs on the legal-analyst agent (host-side, where the
|
||||||
case_number: string;
|
* `claude` CLI lives) — NOT inline in the FastAPI container. The endpoint
|
||||||
force: boolean;
|
* either delegates via a Paperclip wakeup (`queued`) or short-circuits on a
|
||||||
message: string;
|
* 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) {
|
export function useAggregateArguments(caseNumber: string | undefined) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from web.paperclip_client import (
|
|||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
update_project_name as pc_update_project_name,
|
update_project_name as pc_update_project_name,
|
||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
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_agent as pc_wake_ceo,
|
||||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as pc_wake_curator_for_final,
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
@@ -99,6 +100,7 @@ __all__ = [
|
|||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
"pc_wake_analyst_for_appraiser_facts",
|
"pc_wake_analyst_for_appraiser_facts",
|
||||||
|
"pc_wake_analyst_for_argument_aggregation",
|
||||||
# comments / interactions
|
# comments / interactions
|
||||||
"pc_post_comment",
|
"pc_post_comment",
|
||||||
"pc_get_issue_comments",
|
"pc_get_issue_comments",
|
||||||
|
|||||||
94
web/app.py
94
web/app.py
@@ -75,6 +75,7 @@ from web.agent_platform_port import (
|
|||||||
pc_respond_to_interaction,
|
pc_respond_to_interaction,
|
||||||
pc_restore_project,
|
pc_restore_project,
|
||||||
pc_wake_analyst_for_appraiser_facts,
|
pc_wake_analyst_for_appraiser_facts,
|
||||||
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
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".
|
# the FastAPI container it short-circuits with status="llm_unavailable".
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
||||||
async def api_aggregate_arguments(
|
async def api_aggregate_arguments(case_number: str, force: bool = False):
|
||||||
case_number: str,
|
"""Queue claim→argument aggregation by waking the legal-analyst agent.
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
force: bool = False,
|
|
||||||
):
|
|
||||||
"""Aggregate raw claims into distinct legal arguments via Claude.
|
|
||||||
|
|
||||||
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)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
async def _run() -> None:
|
case_id = UUID(case["id"])
|
||||||
try:
|
pool = await db.get_pool()
|
||||||
from legal_mcp.services import argument_aggregator
|
async with pool.acquire() as conn:
|
||||||
result = await argument_aggregator.aggregate_claims_to_arguments(
|
claim_count = await conn.fetchval(
|
||||||
UUID(case["id"]), force=force,
|
"SELECT COUNT(*) FROM claims WHERE case_id = $1", case_id,
|
||||||
)
|
)
|
||||||
logger.info(
|
existing_args = await conn.fetchval(
|
||||||
"aggregate_arguments[%s] finished: %s",
|
"SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1", case_id,
|
||||||
case_number, result,
|
)
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.exception(
|
|
||||||
"aggregate_arguments[%s] failed: %s", case_number, e,
|
|
||||||
)
|
|
||||||
|
|
||||||
background_tasks.add_task(_run)
|
if not claim_count:
|
||||||
return {
|
return {
|
||||||
"status": "started",
|
"status": "no_claims",
|
||||||
"case_number": case_number,
|
"total": 0,
|
||||||
"force": force,
|
"message": (
|
||||||
"message": "Aggregation started in background. Poll /legal-arguments for results.",
|
"אין טענות גולמיות בתיק. הרץ קודם חילוץ טענות (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")
|
@app.get("/api/cases/{case_number}/legal-arguments")
|
||||||
|
|||||||
@@ -1371,3 +1371,121 @@ async def wake_analyst_for_appraiser_facts(
|
|||||||
"analyst_id": analyst_id,
|
"analyst_id": analyst_id,
|
||||||
"main_issue_id": main_issue_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,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user