feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)
תוצר analyze_protocol (איך הטענות הכתובות התפתחו בדיון בעל-פה) היה שכבת ידע-תיק ללא endpoint ותצוגה — נצרך רק downstream ע"י הכותב בבלוק-י. פאנל חדש בתחתית טאב "טיעונים ועמדות", מתחת לכרטיס-הטיעונים (INV-IA1: לא טאב-מקביל). Backend: - SCHEMA_V51: cases.hearing_attendees JSONB — בית קנוני לנוכחות-בדיון (panel_members/appellants_present/respondents_present). נבדל סמנטית מ-decisions.panel_members (מותב חותם-ההחלטה) → אין מסלול-מקביל (G2). - protocol_analyzer._extract_header: מאכלס hearing_attendees; refresh על re-run (מתקן #223), לא דורס עם חילוץ ריק. אישור-משתמש: "אפשרות א" (persist+הצגה). - GET /api/cases/{n}/protocol-analysis — header (תאריך+נוכחים קנוניים) + verdicts מקובצים לפי צד. קריאה-בלבד, container-safe, ריק≠שגיאה. - POST /api/cases/{n}/analyze-protocol — מעיר את המנתח (analyze_protocol מריץ claude מקומי, נעדר בקונטיינר) כתבנית aggregate-arguments. - מגע-Paperclip רק דרך agent_platform_port (G12): wake_analyst_for_protocol_analysis. Frontend: - lib/api/protocol-analysis.ts (hooks read+trigger) + HearingChangesPanel (סרגל-כותרת, פסי-סינון התחזק/נטען-לראשונה, כרטיסים לפי צד, ציטוט-ראיה). - חיווט לטאב arguments מתחת ל-LegalArgumentsPanel. עיצוב: מוקאפ 27-case-hearing-changes-panel אושר ע"י חיים דרך שער Claude Design (X17). Invariants: מקיים G2 (בית קנוני יחיד, לא מסלול-מקביל), G1 (נרמול-במקור), G12 (שער-הפלטפורמה), INV-IA1 (לא טאב-מקביל), INV-AH (evidence_quote כבר נאכף במקור). אימות: py_compile + tsc --noEmit + eslint ירוקים; runtime של ה-endpoint = post-deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ from web.paperclip_client import (
|
||||
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_analyst_for_protocol_analysis as pc_wake_analyst_for_protocol_analysis,
|
||||
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,
|
||||
@@ -106,6 +107,7 @@ __all__ = [
|
||||
"pc_wake_for_precedent_extraction",
|
||||
"pc_wake_analyst_for_appraiser_facts",
|
||||
"pc_wake_analyst_for_argument_aggregation",
|
||||
"pc_wake_analyst_for_protocol_analysis",
|
||||
# comments / interactions
|
||||
"pc_post_comment",
|
||||
"pc_get_issue_comments",
|
||||
|
||||
92
web/app.py
92
web/app.py
@@ -79,6 +79,7 @@ from web.agent_platform_port import (
|
||||
pc_restore_project,
|
||||
pc_wake_analyst_for_appraiser_facts,
|
||||
pc_wake_analyst_for_argument_aggregation,
|
||||
pc_wake_analyst_for_protocol_analysis,
|
||||
rename_case_project,
|
||||
pc_wake_ceo,
|
||||
pc_wake_ceo_for_action,
|
||||
@@ -2659,6 +2660,97 @@ async def api_get_legal_arguments(case_number: str, party: str = ""):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/protocol-analysis")
|
||||
async def api_get_protocol_analysis(case_number: str):
|
||||
"""Hearing-protocol comparative analysis for the "מה קרה בדיון" panel (#226).
|
||||
|
||||
Read-only surface over the case-knowledge produced by ``analyze_protocol``:
|
||||
the hearing header (date + who appeared, from the canonical case columns)
|
||||
plus the per-argument verdicts (strengthened / newly_raised / dropped)
|
||||
grouped by party. Container-safe — pure DB reads, no LLM. Returns an empty
|
||||
analysis (not an error) when the analysis has not been run yet.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
rows = await db.list_protocol_analysis(case_id)
|
||||
|
||||
# Protocol title comes from the analysed document (all rows share one
|
||||
# document_id per idempotent replace). Resolve it via the case's documents.
|
||||
protocol_title = ""
|
||||
protocol_document_id = ""
|
||||
if rows:
|
||||
protocol_document_id = rows[0].get("document_id") or ""
|
||||
docs = await db.list_documents(case_id)
|
||||
match = next(
|
||||
(d for d in docs if str(d.get("id")) == protocol_document_id), None,
|
||||
)
|
||||
if match:
|
||||
protocol_title = match.get("title") or ""
|
||||
|
||||
attendees = case.get("hearing_attendees") or {}
|
||||
hearing_date = case.get("hearing_date")
|
||||
header = {
|
||||
"hearing_date": hearing_date.isoformat() if hearing_date else None,
|
||||
"protocol_title": protocol_title,
|
||||
"protocol_document_id": protocol_document_id,
|
||||
"panel_members": attendees.get("panel_members") or [],
|
||||
"appellants_present": attendees.get("appellants_present") or [],
|
||||
"respondents_present": attendees.get("respondents_present") or [],
|
||||
}
|
||||
|
||||
# Group verdicts by party for the panel's per-side sections, in display order.
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
by_change: dict[str, int] = {}
|
||||
for r in rows:
|
||||
by_party.setdefault(r.get("party_role") or "", []).append(r)
|
||||
ct = r.get("change_type", "")
|
||||
by_change[ct] = by_change.get(ct, 0) + 1
|
||||
|
||||
return {
|
||||
"case_number": case_number,
|
||||
"total": len(rows),
|
||||
"header": header,
|
||||
"by_change": by_change,
|
||||
"by_party": by_party,
|
||||
"analysis": rows,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/analyze-protocol")
|
||||
async def api_analyze_protocol(case_number: str, document_id: str = ""):
|
||||
"""Queue hearing-protocol analysis by waking the legal-analyst agent (#226).
|
||||
|
||||
Same delegation rationale as ``aggregate-arguments``: ``analyze_protocol``
|
||||
calls the local ``claude`` CLI, absent in this container, so we route to the
|
||||
company's analyst rather than running a doomed in-container BackgroundTask.
|
||||
|
||||
Response: {"status": "queued", ...} or {"status": "skipped", "reason": ...}.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
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_protocol_analysis(
|
||||
case_number, company_id=company_id, document_id=document_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("analyst wakeup failed for protocol analysis %s", case_number)
|
||||
raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/direction")
|
||||
async def api_set_direction(case_number: str, req: DirectionRequest):
|
||||
"""Save the approved direction document for the discussion block."""
|
||||
|
||||
@@ -1684,3 +1684,109 @@ async def wake_analyst_for_argument_aggregation(
|
||||
"analyst_id": analyst_id,
|
||||
"main_issue_id": main_issue_id,
|
||||
}
|
||||
|
||||
|
||||
async def wake_analyst_for_protocol_analysis(
|
||||
case_number: str,
|
||||
company_id: str,
|
||||
document_id: str = "",
|
||||
) -> dict:
|
||||
"""Wake the legal-analyst to run the hearing-protocol comparative analysis.
|
||||
|
||||
Triggered by the chair clicking "נתח את פרוטוקול הדיון" / "נתח מחדש" in the
|
||||
"מה קרה בדיון" panel (#226). Same delegation shape as
|
||||
``wake_analyst_for_argument_aggregation``: the FastAPI container cannot run
|
||||
``analyze_protocol`` directly (it calls ``claude_session.query_json()``,
|
||||
which needs the host-side ``claude`` CLI), so we create a child issue under
|
||||
the case's main Paperclip issue, assign it to the company's analyst, and
|
||||
trigger a wakeup. The analyst runs the MCP tool locally and reports back.
|
||||
|
||||
``document_id`` optionally pins the analysis to a specific protocol document
|
||||
(when a case holds several protocols — #223).
|
||||
|
||||
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 protocol analysis 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"]
|
||||
|
||||
doc_clause = f", document_id=\"{document_id}\"" if document_id.strip() else ""
|
||||
description = (
|
||||
f"חיים ביקש ניתוח פרוטוקול-דיון בתיק {case_number}.\n\n"
|
||||
f"הרץ `mcp__legal-ai__analyze_protocol(case_number=\"{case_number}\"{doc_clause})` "
|
||||
f"וכתוב comment בעברית עם תוצאת הניתוח — כמה טענות התחזקו בדיון, כמה נטענו "
|
||||
f"לראשונה, וכמה ירדו. אם אין פרוטוקול ועדת-ערר בתיק או שאין טיעונים מאוגדים "
|
||||
f"להשוואה, דווח ב-comment מה חסר וסגור את ה-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",
|
||||
"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"analyze_protocol_{case_number}",
|
||||
"payload": {
|
||||
"issueId": sub_issue_id,
|
||||
"mutation": "assignment",
|
||||
"caseNumber": case_number,
|
||||
},
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
logger.info(
|
||||
"Analyst wakeup for protocol analysis on case %s: sub_issue=%s "
|
||||
"analyst=%s doc=%s wake=%s",
|
||||
case_number, sub_issue_id, analyst_id, document_id or "auto", 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