feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 59s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

תוצר 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:
2026-07-05 12:16:35 +00:00
parent 6414e5f94a
commit ab5d34a9d3
8 changed files with 753 additions and 5 deletions

View File

@@ -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"])

View File

@@ -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,
}

View File

@@ -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({
</AccordionContent>
</AccordionItem>
</Accordion>
{/* מה קרה בדיון — 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. */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<HearingChangesPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),

View File

@@ -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<ProtocolPartyRole, string> = {
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<ProtocolChangeType, string> = {
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<ProtocolChangeType, string> = {
strengthened: "border-s-gold",
newly_raised: "border-s-emerald-500",
dropped: "border-s-rule",
};
function ChangeIcon({ type }: { type: ProtocolChangeType }) {
if (type === "strengthened") return <ArrowUp className="size-3.5" aria-hidden />;
if (type === "newly_raised") return <Plus className="size-3.5" aria-hidden />;
return <ArrowDown className="size-3.5" aria-hidden />;
}
type FilterKey = "all" | ProtocolChangeType;
function AttendeeColumn({
label,
dotTone,
names,
}: {
label: string;
dotTone: string;
names: string[];
}) {
if (!names.length) return null;
return (
<div className="min-w-[210px] flex-1">
<div className="text-gold-deep mb-1.5 flex items-center gap-1.5 text-[0.7rem] font-bold">
<span className={`size-1.5 flex-none rounded-full ${dotTone}`} aria-hidden />
{label}
</div>
<div className="text-ink-soft text-xs leading-relaxed">
{names.join(" · ")}
</div>
</div>
);
}
function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) {
return (
<div
className={`bg-surface border-rule overflow-hidden rounded-lg border border-s-4 shadow-sm ${CHANGE_ACCENT[row.change_type]}`}
>
<div className="space-y-2.5 px-4 py-3.5">
<div className="flex flex-wrap items-start gap-2.5">
<Badge
variant="outline"
className={`${CHANGE_BADGE_TONE[row.change_type]} flex-none gap-1 text-[0.72rem] font-bold`}
>
<ChangeIcon type={row.change_type} />
{CHANGE_LABELS_HE[row.change_type]}
</Badge>
<span className="text-navy flex-1 text-sm font-bold leading-snug">
{row.argument_title}
</span>
</div>
{row.summary && (
<p className="text-ink-soft text-[0.82rem] leading-relaxed">
{row.summary}
</p>
)}
{row.sharpened_question && (
<div className="bg-parchment border-rule-soft rounded-md border px-3 py-2">
<div className="text-gold-deep mb-1 flex items-center gap-1.5 text-[0.66rem] font-bold">
<HelpCircle className="size-3" aria-hidden />
השאלה שהתחדדה
</div>
<div className="text-ink text-xs leading-relaxed">
{row.sharpened_question}
</div>
</div>
)}
{row.evidence_quote && (
<blockquote className="border-rule text-ink-muted border-s-[3px] px-3 text-xs italic leading-relaxed">
&ldquo;{row.evidence_quote}&rdquo;
<span className="text-ink-muted mt-1 block text-[0.68rem] not-italic font-semibold">
מתוך פרוטוקול הדיון
</span>
</blockquote>
)}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{row.change_type === "newly_raised" ? (
<span className="text-emerald-800 inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Sparkles className="size-3.5" aria-hidden />
אין מקבילה בכתב עלה לראשונה בדיון
</span>
) : row.argument_id ? (
<span className="text-ink-muted inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Link2 className="size-3.5" aria-hidden />
מבוסס על טיעון כתוב קיים
</span>
) : null}
</div>
</div>
</div>
);
}
type HearingChangesPanelProps = {
caseNumber: string;
};
export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) {
const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber);
const analyze = useAnalyzeProtocol(caseNumber);
const [filter, setFilter] = useState<FilterKey>("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 = (
<Button
variant="outline"
size="sm"
disabled={analyze.isPending}
onClick={handleAnalyze}
>
{analyze.isPending ? (
<Loader2 className="me-1.5 size-3.5 animate-spin" />
) : (
<RotateCw className="me-1.5 size-3.5" />
)}
{data?.total ? "נתח מחדש" : "נתח את פרוטוקול הדיון"}
</Button>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-navy flex items-center gap-2 text-base font-semibold">
<MessageSquareText className="text-gold-deep size-5" aria-hidden />
מה קרה בדיון
</h2>
<p className="text-ink-muted mt-0.5 max-w-2xl text-xs leading-relaxed">
השוואה בין הטענות בכתב לבין מה שנטען בדיון בעל-פה אילו טענות התחזקו
ואילו נטענו לראשונה, כדי שהדיון (בלוק י) יתייחס לגלגול העדכני.
</p>
</div>
{runButton}
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message}
</p>
) : !data?.total ? (
<div className="bg-surface border-rule rounded-lg border border-dashed px-6 py-8 text-center">
<h3 className="text-navy text-sm font-bold">טרם נותח פרוטוקול</h3>
<p className="text-ink-muted mx-auto mt-1.5 max-w-md text-xs leading-relaxed">
כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה
התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע.
</p>
<div className="mt-4 flex justify-center">{runButton}</div>
</div>
) : (
<>
{/* hearing header strip */}
{header && (
<div className="bg-parchment border-rule rounded-lg border px-5 py-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="bg-gold-wash border-rule flex size-9 flex-none items-center justify-center rounded-lg border">
<CalendarDays className="text-gold-deep size-5" aria-hidden />
</span>
<div className="flex-1">
<div className="text-navy text-[0.95rem] font-bold">
{header.protocol_title || "פרוטוקול דיון"}
</div>
<div className="text-ink-muted mt-0.5 text-xs">
{header.hearing_date
? `תאריך דיון: ${header.hearing_date}`
: "תאריך דיון לא זוהה"}
{header.panel_members.length > 0 &&
` · מותב: ${header.panel_members.join(", ")}`}
</div>
</div>
<Badge
variant="outline"
className="bg-info-bg text-info border-info/30 flex-none rounded-full text-[0.72rem] font-semibold"
>
פרוטוקול ועדת-הערר
</Badge>
</div>
{(header.appellants_present.length > 0 ||
header.respondents_present.length > 0) && (
<div className="border-rule-soft mt-3 flex flex-wrap gap-6 border-t pt-3">
<AttendeeColumn
label="נכחו מטעם העוררים"
dotTone="bg-info"
names={header.appellants_present}
/>
<AttendeeColumn
label="נכחו מטעם המשיבים"
dotTone="bg-gold-deep"
names={header.respondents_present}
/>
</div>
)}
</div>
)}
{/* filter pills */}
<div className="flex flex-wrap items-center gap-2">
<FilterPill
active={filter === "all"}
onClick={() => setFilter("all")}
label="הכל"
count={data.total}
/>
{strengthened > 0 && (
<FilterPill
active={filter === "strengthened"}
onClick={() => setFilter("strengthened")}
label="התחזקו"
count={strengthened}
icon={<ArrowUp className="size-3.5" aria-hidden />}
/>
)}
{newlyRaised > 0 && (
<FilterPill
active={filter === "newly_raised"}
onClick={() => setFilter("newly_raised")}
label="נטענו לראשונה"
count={newlyRaised}
icon={<Plus className="size-3.5" aria-hidden />}
/>
)}
{dropped > 0 && (
<FilterPill
active={filter === "dropped"}
onClick={() => setFilter("dropped")}
label="נזנחו"
count={dropped}
icon={<ArrowDown className="size-3.5" aria-hidden />}
/>
)}
</div>
{/* per-party sections */}
{sections.map(({ role, rows }) => (
<div key={role || "unassigned"} className="space-y-2.5">
<div className="flex items-center gap-2.5">
<span
className={`h-5 w-1.5 flex-none rounded ${PARTY_MARK_TONE[role]}`}
aria-hidden
/>
<span className="text-navy text-[0.95rem] font-bold">
{PROTOCOL_PARTY_LABELS_HE[role]}
</span>
<span className="text-ink-muted text-xs">{rows.length}</span>
</div>
<div className="space-y-2.5">
{rows.map((r) => (
<DevelopmentCard key={r.id} row={r} />
))}
</div>
</div>
))}
</>
)}
</div>
);
}
function FilterPill({
active,
onClick,
label,
count,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
active
? "bg-navy border-navy text-white"
: "bg-surface border-rule text-ink-soft hover:bg-parchment"
}`}
>
{icon}
{label}
<span className="font-bold">{count}</span>
</button>
);
}

View File

@@ -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<Record<ProtocolChangeType, number>>;
by_party: Partial<Record<ProtocolPartyRole, ProtocolAnalysisRow[]>>;
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<ProtocolAnalysisResponse>(
`/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<AnalyzeProtocolResult>(
`/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<ProtocolChangeType, string> = {
strengthened: "התחזק בדיון",
newly_raised: "נטען לראשונה",
dropped: "נזנח בדיון",
};
export const PROTOCOL_PARTY_LABELS_HE: Record<ProtocolPartyRole, string> = {
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",
"",
];

View File

@@ -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",

View File

@@ -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."""

View File

@@ -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,
}