From 9c6896df900833c967050097e6f3a6f7d5ff577e Mon Sep 17 00:00:00 2001 From: Chaim Date: Fri, 12 Jun 2026 06:19:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(learning):=20FU-2=20UI=20=E2=80=94=20?= =?UTF-8?q?=D7=94=D7=AA=D7=9C=D7=91=D7=98=D7=95=D7=AA-=D7=94=D7=A4=D7=90?= =?UTF-8?q?=D7=A0=D7=9C=20=D7=91=D7=9E=D7=A1=D7=9A-=D7=90=D7=99=D7=A9?= =?UTF-8?q?=D7=95=D7=A8=20=D7=94=D7=99=D7=95"=D7=A8=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מציג את התלבטות 3-השופטים (הצבעה+נימוק לכל לינאז' + ה-verdict) בתוך כרטיס-האישור הקיים של דפנה ב-/precedents → "ממתין לאישור", כדי שהכרעתה — תווית-הזהב שהלולאה לומדת ממנה — תהיה מיודעת ב*למה* הפאנל נחלק. אושר ב-Claude Design (כרטיס 18-halacha-deliberation). Backend (opt-in, ברירת-מחדל off — קוראים קיימים לא מושפעים): - db.list_halachot(include_panel_round=True) → _annotate_panel_rounds מצרף את הסבב האחרון מ-halacha_panel_rounds (DISTINCT ON, latest). - GET /api/halachot?include_panel_round=true. Frontend: - Halacha.panel_round (טיפוס ידני; ה-endpoint מחזיר dict). - תור-הסקירה (useHalachotPending) מבקש include_panel_round בשני הדליים (clean=keep, needsFix=nli/entailed). - רכיב PanelDeliberation: טבלת 3-שופטים (✓נתמך/✗הכלל-חורג + נימוק), תג-ורדיקט "פיצול 2:1", ושורת "שורש המחלוקת" (קפדני↔תמצית) רק בפיצול-entailment. מוזרק אחרי רשת הכלל/ציטוט. שער יחיד — אין עמוד/שער חדש (INV-IA/G10); display-only, לא נוגע ב-review_status. ולידציה: py_compile + tsc --noEmit + eslint נקיים; בדיקה פונקציונלית: panel_round מצורף ל-6 שיש להן סבב, 1994 בלי. חלק מ-#133 (FU-2). דורש deploy + (אופ') npm run api:types אחרי. Co-Authored-By: Claude Opus 4.8 --- mcp-server/src/legal_mcp/services/db.py | 39 +++++++++ .../precedents/halacha-review-panel.tsx | 83 ++++++++++++++++++- web-ui/src/lib/api/precedent-library.ts | 21 ++++- web/app.py | 10 ++- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index d324d7c..6926775 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -4466,6 +4466,7 @@ async def list_halachot( order_by_priority: bool = False, cluster: bool = False, include_equivalents: bool = False, + include_panel_round: bool = False, ) -> list[dict]: """List halachot with optional triage controls (#84). @@ -4549,9 +4550,47 @@ async def list_halachot( await _annotate_clusters(pool, out) if include_equivalents and out: await _annotate_equivalents(pool, out) + if include_panel_round and out: + await _annotate_panel_rounds(pool, out) return out +async def _annotate_panel_rounds(pool, out: list[dict]) -> None: + """Attach the LATEST 3-judge panel round to each row (#133/FU-2), display-only. + + Surfaces the panel's deliberation — each lineage's vote + rationale and the + derived verdict — inside the chair's review card, so her decision (the gold + label the loop learns from) is informed by *why* the judges split. Reads the + capture table halacha_panel_rounds; never affects review_status (INV-G10).""" + ids = [d["id"] for d in out] + rows = await pool.fetch( + "SELECT DISTINCT ON (halacha_id) halacha_id, question, verdict, " + "applied_action, round_ts, claude_vote, claude_reason, deepseek_vote, " + "deepseek_reason, gemini_vote, gemini_reason " + "FROM halacha_panel_rounds WHERE halacha_id = ANY($1::uuid[]) " + "ORDER BY halacha_id, round_ts DESC", + ids, + ) + by_id = { + str(r["halacha_id"]): { + "question": r["question"], + "verdict": r["verdict"], + "applied_action": r["applied_action"], + "round_ts": r["round_ts"].isoformat() if r["round_ts"] else None, + "judges": [ + {"model": "claude", "vote": r["claude_vote"], "reason": r["claude_reason"]}, + {"model": "deepseek", "vote": r["deepseek_vote"], "reason": r["deepseek_reason"]}, + {"model": "gemini", "vote": r["gemini_vote"], "reason": r["gemini_reason"]}, + ], + } + for r in rows + } + for d in out: + pr = by_id.get(str(d["id"])) + if pr: + d["panel_round"] = pr + + async def _annotate_clusters(pool, out: list[dict]) -> None: """Add cluster_id + cluster_size to each row (#84.2), display-only. diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index 59bed2e..02fa9a9 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react"; +import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -43,6 +43,85 @@ function cleanCitation(s: string | null | undefined): string { type EditState = { rule_statement: string; reasoning_summary: string }; +// ─── Panel deliberation (#133/FU-2) ─────────────────────────────────────────── +// Surfaces the 3-judge panel's vote+rationale inside the chair's review card so +// her decision — the gold label the active-learning loop learns from — is informed +// by WHY the panel split. Display-only; never affects review_status (INV-G10). + +const JUDGE_META: Record = { + claude: { name: "Claude", lineage: "Anthropic" }, + deepseek: { name: "DeepSeek", lineage: "DeepSeek" }, + gemini: { name: "Gemini", lineage: "Google" }, +}; + +const VERDICT_META: Record = { + unanimous_yes: { label: "פה-אחד", cls: "bg-success text-white" }, + unanimous_no: { label: "פה-אחד נגד", cls: "bg-danger text-white" }, + split: { label: "פיצול", cls: "bg-warn text-white" }, + incomplete: { label: "חלקי", cls: "bg-ink-muted text-white" }, +}; + +function PanelDeliberation({ round }: { round: NonNullable }) { + const isEntail = round.question === "entailed"; + const yesLabel = isEntail ? "נתמך" : "לשמירה"; + const noLabel = isEntail ? "הכלל חורג" : "לפסילה"; + const question = isEntail + ? "האם הציטוט תומך בכלל ואינו מרחיב מעבר לו?" + : "האם זו הלכה בת-הכללה לשמירה?"; + const votes = round.judges.map((j) => j.vote).filter((v): v is boolean => v !== null); + const yes = votes.filter(Boolean).length; + const no = votes.length - yes; + const v = VERDICT_META[round.verdict] ?? VERDICT_META.incomplete; + const ratio = round.verdict === "split" ? ` ${Math.max(yes, no)}:${Math.min(yes, no)}` : ""; + // The strict-vs-gist explainer only makes sense on a split over entailment. + const showHint = round.verdict === "split" && isEntail; + return ( +
+
+ התלבטות הפאנל + + {v.label}{ratio} + + + השאלה: ״{question}״ + +
+ {round.judges.map((j) => { + const yesVote = j.vote === true; + const noVote = j.vote === false; + const meta = JUDGE_META[j.model] ?? { name: j.model, lineage: "" }; + return ( +
+
+ {meta.name} + {meta.lineage} +
+ + {j.vote === null ? "— ללא" : yesVote ? `✓ ${yesLabel}` : `✗ ${noLabel}`} + +

{j.reason || "—"}

+
+ ); + })} + {showHint && ( +
+ +

+ שורש המחלוקת:{" "} + סטנדרט קפדני (כל פרט בכלל חייב להופיע בציטוט) מול סטנדרט-תמצית (הגרעין המשפטי נתמך). + הכרעתך קובעת איזה סטנדרט הפאנל יאמץ. +

+
+ )} +
+ ); +} + // ─── Pending-queue card (full interactions) ─────────────────────────────────── function HalachaCard({ @@ -158,6 +237,8 @@ function HalachaCard({ + {h.panel_round && } + {(editing || h.reasoning_summary) && (
תמצית ההיגיון
diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 65e1302..acb9748 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -111,6 +111,21 @@ export type Halacha = { rule_statement: string; cosine: number | null; }[]; + /* #133/FU-2 — the latest 3-judge panel deliberation (present only when fetched + * with include_panel_round). Surfaced in the review card so the chair sees WHY + * the panel split before she decides; her decision is the gold label the + * active-learning loop learns from. Capture-only — does not affect review_status. */ + panel_round?: { + question: "keep" | "entailed"; + verdict: "unanimous_yes" | "unanimous_no" | "split" | "incomplete"; + applied_action: string; + round_ts: string | null; + judges: { + model: "claude" | "deepseek" | "gemini"; + vote: boolean | null; + reason: string; + }[]; + }; }; export type RelatedCase = { @@ -597,9 +612,11 @@ export function useHalachotPending( ) { const { limit = 200, needsFix = false } = opts; const qs = needsFix - ? `review_status=pending_review&exclude_low_quality=false&limit=${limit}` + ? `review_status=pending_review&exclude_low_quality=false` + + `&include_panel_round=true&limit=${limit}` : `review_status=pending_review&exclude_low_quality=true` - + `&order_by_priority=true&cluster=true&include_equivalents=true&limit=${limit}`; + + `&order_by_priority=true&cluster=true&include_equivalents=true` + + `&include_panel_round=true&limit=${limit}`; return useQuery({ queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"], queryFn: async ({ signal }) => { diff --git a/web/app.py b/web/app.py index bda56a8..6f42367 100644 --- a/web/app.py +++ b/web/app.py @@ -7123,12 +7123,15 @@ async def halachot_list( order_by_priority: bool = False, cluster: bool = False, include_equivalents: bool = False, + include_panel_round: bool = False, ): """List halachot. ``exclude_low_quality`` hides flagged items (#84.1), ``order_by_priority`` switches to the active-learning order (#84.3), - ``cluster`` annotates near-duplicate groups for one-card review (#84.2), and - ``include_equivalents`` attaches cross-precedent parallel-authority links. All - default off so existing callers are unaffected; the review queue opts in.""" + ``cluster`` annotates near-duplicate groups for one-card review (#84.2), + ``include_equivalents`` attaches cross-precedent parallel-authority links, and + ``include_panel_round`` attaches the latest 3-judge panel deliberation so the + chair sees why the panel split (#133/FU-2). All default off so existing callers + are unaffected; the review queue opts in.""" cid: UUID | None = None if case_law_id: try: @@ -7144,6 +7147,7 @@ async def halachot_list( order_by_priority=order_by_priority, cluster=cluster, include_equivalents=include_equivalents, + include_panel_round=include_panel_round, ) return {"items": rows, "count": len(rows)}