diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 5322111..249c57a 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1900,6 +1900,24 @@ CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name """ +# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The +# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their +# counsel) and, when stated, the presiding panel. ``_extract_header`` already +# extracts this feed but only ``hearing_date`` had a canonical home (on cases); +# the attendee lists were returned as provenance and dropped. This column gives +# them a home so the panel can display them. +# +# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path): +# that column is the authoring tribunal recorded on a specific written-decision +# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who +# was present at the *hearing*, extracted from the protocol — hearing-event +# provenance, not decision-authorship. Shape: +# {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]} +SCHEMA_V51_SQL = """ +ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}'; +""" + + # Stable, arbitrary key for the session-level advisory lock that serialises # schema DDL across processes. Every short-lived process (cron drains, services) # re-runs the idempotent migrations on startup; without this lock two processes @@ -1972,6 +1990,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None: await conn.execute(SCHEMA_V48_SQL) await conn.execute(SCHEMA_V49_SQL) await conn.execute(SCHEMA_V50_SQL) + await conn.execute(SCHEMA_V51_SQL) async def init_schema() -> None: @@ -2194,7 +2213,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None: set_clauses = [] values = [] for i, (key, val) in enumerate(fields.items(), start=2): - if key in ("appellants", "respondents", "tags"): + if key in ("appellants", "respondents", "tags", "hearing_attendees"): val = json.dumps(val) set_clauses.append(f"{key} = ${i}") values.append(val) @@ -2207,7 +2226,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None: def _row_to_case(row: asyncpg.Record) -> dict: d = dict(row) - for field in ("appellants", "respondents", "tags"): + for field in ("appellants", "respondents", "tags", "hearing_attendees"): if isinstance(d.get(field), str): d[field] = json.loads(d[field]) d["id"] = str(d["id"]) diff --git a/mcp-server/src/legal_mcp/services/protocol_analyzer.py b/mcp-server/src/legal_mcp/services/protocol_analyzer.py index b96e6c5..8aec83d 100644 --- a/mcp-server/src/legal_mcp/services/protocol_analyzer.py +++ b/mcp-server/src/legal_mcp/services/protocol_analyzer.py @@ -259,13 +259,25 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict: except ValueError: logger.info("protocol header: unparseable hearing_date %r", hearing_date) + # Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no + # chair-entry path to protect, and re-running to point at the correct ערר + # protocol (#223) must refresh who appeared — so write whenever the operative + # protocol yielded any names, but never clobber good data with an empty + # extraction (all-empty feed → leave the prior snapshot intact). + attendees = { + "panel_members": feed.get("panel_members") or [], + "appellants_present": feed.get("appellants_present") or [], + "respondents_present": feed.get("respondents_present") or [], + } + if any(attendees.values()): + await db.update_case(case_id, hearing_attendees=attendees) + applied["hearing_attendees"] = True + return { "status": "ok", "feed": { "hearing_date": hearing_date, - "panel_members": feed.get("panel_members") or [], - "appellants_present": feed.get("appellants_present") or [], - "respondents_present": feed.get("respondents_present") or [], + **attendees, }, "applied_to_case": applied, } diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx index 2e4142c..8ada718 100644 --- a/web-ui/src/app/cases/[caseNumber]/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/page.tsx @@ -17,6 +17,7 @@ import { DraftsPanel } from "@/components/cases/drafts-panel"; import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel"; import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel"; import { PositionsPanel } from "@/components/cases/positions-panel"; +import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel"; import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel"; import { AgentActivityFeed } from "@/components/cases/agent-activity-feed"; import { AgentActivityPreview } from "@/components/cases/agent-activity-preview"; @@ -181,6 +182,14 @@ export default function CaseDetailPage({ + {/* מה קרה בדיון — comparative analysis of the hearing protocol vs. + the written pleadings (#226). Sits below the aggregated arguments + because it speaks to those same arguments in their oral gloss. */} + + + + + {/* אימות פסיקה — per-argument supporting-precedent verify gate (#154), diff --git a/web-ui/src/components/cases/hearing-changes-panel.tsx b/web-ui/src/components/cases/hearing-changes-panel.tsx new file mode 100644 index 0000000..f8dcd7c --- /dev/null +++ b/web-ui/src/components/cases/hearing-changes-panel.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + ArrowDown, + ArrowUp, + CalendarDays, + HelpCircle, + Link2, + Loader2, + MessageSquareText, + Plus, + RotateCw, + Sparkles, +} from "lucide-react"; +import { toast } from "sonner"; +import { + CHANGE_LABELS_HE, + PROTOCOL_PARTY_LABELS_HE, + PROTOCOL_PARTY_ORDER, + useAnalyzeProtocol, + useProtocolAnalysis, + type ProtocolAnalysisRow, + type ProtocolChangeType, + type ProtocolPartyRole, +} from "@/lib/api/protocol-analysis"; + +const PARTY_MARK_TONE: Record = { + appellant: "bg-info", + respondent: "bg-gold-deep", + committee: "bg-success", + permit_applicant: "bg-warn", + unknown: "bg-ink-muted", + "": "bg-ink-muted", +}; + +const CHANGE_BADGE_TONE: Record = { + strengthened: "bg-gold-wash text-gold-deep border-gold/40", + newly_raised: "bg-emerald-50 text-emerald-900 border-emerald-200", + dropped: "bg-rule-soft text-ink-soft border-rule", +}; + +const CHANGE_ACCENT: Record = { + strengthened: "border-s-gold", + newly_raised: "border-s-emerald-500", + dropped: "border-s-rule", +}; + +function ChangeIcon({ type }: { type: ProtocolChangeType }) { + if (type === "strengthened") return ; + if (type === "newly_raised") return ; + return ; +} + +type FilterKey = "all" | ProtocolChangeType; + +function AttendeeColumn({ + label, + dotTone, + names, +}: { + label: string; + dotTone: string; + names: string[]; +}) { + if (!names.length) return null; + return ( +
+
+ + {label} +
+
+ {names.join(" · ")} +
+
+ ); +} + +function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) { + return ( +
+
+
+ + + {CHANGE_LABELS_HE[row.change_type]} + + + {row.argument_title} + +
+ + {row.summary && ( +

+ {row.summary} +

+ )} + + {row.sharpened_question && ( +
+
+ + השאלה שהתחדדה +
+
+ {row.sharpened_question} +
+
+ )} + + {row.evidence_quote && ( +
+ “{row.evidence_quote}” + + — מתוך פרוטוקול הדיון + +
+ )} + +
+ {row.change_type === "newly_raised" ? ( + + + אין מקבילה בכתב — עלה לראשונה בדיון + + ) : row.argument_id ? ( + + + מבוסס על טיעון כתוב קיים + + ) : null} +
+
+
+ ); +} + +type HearingChangesPanelProps = { + caseNumber: string; +}; + +export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) { + const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber); + const analyze = useAnalyzeProtocol(caseNumber); + const [filter, setFilter] = useState("all"); + + const handleAnalyze = () => { + analyze.mutate(undefined, { + onSuccess: (res) => { + if (res.status === "queued") { + toast.success("נשלח למנתח המשפטי — הניתוח ירוץ ברקע; רענן בעוד כמה דקות."); + } else { + toast.warning( + `לא ניתן להריץ אוטומטית (${res.reason}). ניתן להריץ ידנית מ-Claude Code.`, + ); + } + }, + onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`), + }); + }; + + const header = data?.header; + const counts = data?.by_change ?? {}; + const strengthened = counts.strengthened ?? 0; + const newlyRaised = counts.newly_raised ?? 0; + const dropped = counts.dropped ?? 0; + + const sections = useMemo(() => { + if (!data) return []; + return PROTOCOL_PARTY_ORDER.map((role) => { + const rows = (data.by_party[role] ?? []).filter( + (r) => filter === "all" || r.change_type === filter, + ); + return { role, rows }; + }).filter((s) => s.rows.length > 0); + }, [data, filter]); + + const runButton = ( + + ); + + return ( +
+
+
+

+ + מה קרה בדיון +

+

+ השוואה בין הטענות בכתב לבין מה שנטען בדיון בעל-פה — אילו טענות התחזקו + ואילו נטענו לראשונה, כדי שהדיון (בלוק י) יתייחס לגלגול העדכני. +

+
+ {runButton} +
+ + {isPending ? ( +
+ + +
+ ) : isError ? ( +

+ שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message} +

+ ) : !data?.total ? ( +
+

טרם נותח פרוטוקול

+

+ כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה + התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע. +

+
{runButton}
+
+ ) : ( + <> + {/* hearing header strip */} + {header && ( +
+
+ + + +
+
+ {header.protocol_title || "פרוטוקול דיון"} +
+
+ {header.hearing_date + ? `תאריך דיון: ${header.hearing_date}` + : "תאריך דיון לא זוהה"} + {header.panel_members.length > 0 && + ` · מותב: ${header.panel_members.join(", ")}`} +
+
+ + פרוטוקול ועדת-הערר + +
+ {(header.appellants_present.length > 0 || + header.respondents_present.length > 0) && ( +
+ + +
+ )} +
+ )} + + {/* filter pills */} +
+ setFilter("all")} + label="הכל" + count={data.total} + /> + {strengthened > 0 && ( + setFilter("strengthened")} + label="התחזקו" + count={strengthened} + icon={} + /> + )} + {newlyRaised > 0 && ( + setFilter("newly_raised")} + label="נטענו לראשונה" + count={newlyRaised} + icon={} + /> + )} + {dropped > 0 && ( + setFilter("dropped")} + label="נזנחו" + count={dropped} + icon={} + /> + )} +
+ + {/* per-party sections */} + {sections.map(({ role, rows }) => ( +
+
+ + + {PROTOCOL_PARTY_LABELS_HE[role]} + + {rows.length} +
+
+ {rows.map((r) => ( + + ))} +
+
+ ))} + + )} +
+ ); +} + +function FilterPill({ + active, + onClick, + label, + count, + icon, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; + icon?: React.ReactNode; +}) { + return ( + + ); +} diff --git a/web-ui/src/lib/api/protocol-analysis.ts b/web-ui/src/lib/api/protocol-analysis.ts new file mode 100644 index 0000000..7c2ec83 --- /dev/null +++ b/web-ui/src/lib/api/protocol-analysis.ts @@ -0,0 +1,134 @@ +/** + * Protocol comparative analysis — the "מה קרה בדיון" panel (#226). + * + * `analyze_protocol` compares the ערר hearing protocol against the written + * pleadings and records, per argument, whether it was strengthened, newly + * raised, or dropped at the hearing — plus the sharpened legal question and a + * verbatim evidence quote. This module exposes the read + trigger endpoints. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "./client"; +import type { LegalArgumentParty } from "./legal-arguments"; + +export type ProtocolChangeType = "strengthened" | "newly_raised" | "dropped"; + +/** Empty string = an unassigned party_role on a verdict row. */ +export type ProtocolPartyRole = LegalArgumentParty | ""; + +export type ProtocolAnalysisRow = { + id: string; + case_id: string; + document_id: string; + party_role: ProtocolPartyRole; + change_type: ProtocolChangeType; + /** The pleaded argument this verdict matched (strengthened/dropped); null for newly_raised. */ + argument_id: string | null; + argument_title: string; + summary: string; + sharpened_question: string; + evidence_quote: string; + page_number: number | null; + created_at?: string; +}; + +export type ProtocolHeader = { + hearing_date: string | null; + protocol_title: string; + protocol_document_id: string; + panel_members: string[]; + appellants_present: string[]; + respondents_present: string[]; +}; + +export type ProtocolAnalysisResponse = { + case_number: string; + total: number; + header: ProtocolHeader; + by_change: Partial>; + by_party: Partial>; + analysis: ProtocolAnalysisRow[]; +}; + +export const protocolAnalysisKeys = { + all: ["protocol-analysis"] as const, + byCase: (caseNumber: string) => + [...protocolAnalysisKeys.all, caseNumber] as const, +}; + +export function useProtocolAnalysis(caseNumber: string | undefined) { + return useQuery({ + queryKey: protocolAnalysisKeys.byCase(caseNumber ?? ""), + queryFn: ({ signal }) => + apiRequest( + `/api/cases/${caseNumber}/protocol-analysis`, + { signal }, + ), + enabled: Boolean(caseNumber), + staleTime: 10_000, + }); +} + +/** + * The analysis runs on the legal-analyst agent (host-side, where the `claude` + * CLI lives) — NOT inline in the FastAPI container. The endpoint delegates via + * a Paperclip wakeup (`queued`), or reports `skipped` when no analyst route is + * available (the chair can then run the MCP tool manually). + */ +export type AnalyzeProtocolResult = + | { + status: "queued"; + sub_issue_id: string; + analyst_id: string; + main_issue_id: string; + } + | { + status: "skipped"; + reason: "no_api_key" | "no_analyst" | "no_issue" | string; + company_id?: string; + }; + +export function useAnalyzeProtocol(caseNumber: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (documentId?: string) => + apiRequest( + `/api/cases/${caseNumber}/analyze-protocol${ + documentId ? `?document_id=${encodeURIComponent(documentId)}` : "" + }`, + { method: "POST" }, + ), + onSuccess: () => { + if (caseNumber) { + qc.invalidateQueries({ + queryKey: protocolAnalysisKeys.byCase(caseNumber), + }); + } + }, + }); +} + +export const CHANGE_LABELS_HE: Record = { + strengthened: "התחזק בדיון", + newly_raised: "נטען לראשונה", + dropped: "נזנח בדיון", +}; + +export const PROTOCOL_PARTY_LABELS_HE: Record = { + appellant: "עוררים", + respondent: "משיבים", + committee: "ועדה מקומית", + permit_applicant: "מבקשי היתר", + unknown: "צד לא מזוהה", + "": "צד לא מסווג", +}; + +/** Display order for the per-party sections. */ +export const PROTOCOL_PARTY_ORDER: ProtocolPartyRole[] = [ + "appellant", + "committee", + "respondent", + "permit_applicant", + "unknown", + "", +]; diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index 1232d5b..de6a7b9 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -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", diff --git a/web/app.py b/web/app.py index e2bebf6..d502bde 100644 --- a/web/app.py +++ b/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.""" diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 455c009..a471751 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -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, + }