From 5108c854cf97a822b61d70f130c2728383b212e7 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 20 Jun 2026 18:19:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(citation-verify):=20frontend=20"=D7=90?= =?UTF-8?q?=D7=99=D7=9E=D7=95=D7=AA=20=D7=A4=D7=A1=D7=99=D7=A7=D7=94"=20ta?= =?UTF-8?q?b=20in=20the=20decision=20editor=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the citation-verification panel (backend in #323), as the agreed third tab in /compose — matching the approved X17 mockup 24-citation-verification: - lib/api/citation-verification.ts: hand-written types + useCitationVerification query + useVerifyCitation mutation (POST verify, invalidates the view). - components/compose/citation-verification-panel.tsx: per legal argument → supporting corpus precedents with the cited_by authority chips (אומץ ×N / אובחן ×N), the exact supporting_quote, ✓ מאמת / ✗ לא רלוונטי verify actions, a per-citation chair-note field, and the per-issue 📡 radar (unlinked digests, pointer-only). Summary strip + INV-AH/INV-DIG1 reminders. - compose page: third tab "אימות פסיקה" alongside עורך הבלוקים / עמדות וטענות. No api:types regen needed (endpoints return assembled dicts; types hand-written per the lib/api convention). tsc + eslint clean. Invariants: INV-AH (writer cites only verified), INV-DIG1 (digest never cited), G2 (consumes the one backend view). Visual matches the gate-approved mockup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/cases/[caseNumber]/compose/page.tsx | 7 + .../compose/citation-verification-panel.tsx | 261 ++++++++++++++++++ web-ui/src/lib/api/citation-verification.ts | 103 +++++++ 3 files changed, 371 insertions(+) create mode 100644 web-ui/src/components/compose/citation-verification-panel.tsx create mode 100644 web-ui/src/lib/api/citation-verification.ts diff --git a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx index 52c7c27..5bfcf43 100644 --- a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { SubsectionCard } from "@/components/compose/subsection-card"; import { PrecedentsSection } from "@/components/compose/precedents-section"; +import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel"; import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel"; import { Markdown } from "@/components/ui/markdown"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -276,6 +277,7 @@ export default function ComposePage({ עורך הבלוקים עמדות וטענות + אימות פסיקה {/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */} @@ -283,6 +285,11 @@ export default function ComposePage({ + {/* Tab 3 — citation verification: per-argument support + verify gate (#154) */} + + + + {/* Tab 2 — chair positions on the analyst's threshold-claims + issues */} {analysis.isPending ? ( diff --git a/web-ui/src/components/compose/citation-verification-panel.tsx b/web-ui/src/components/compose/citation-verification-panel.tsx new file mode 100644 index 0000000..8da64f4 --- /dev/null +++ b/web-ui/src/components/compose/citation-verification-panel.tsx @@ -0,0 +1,261 @@ +"use client"; + +/** + * "אימות פסיקה" tab (X11 Phase 2 / #154) — per legal argument, the in-corpus + * supporting precedents with the cumulative authority signal (cited_by), a verify + * gate + chair note, and per-issue radar (unlinked digests). The writer cites only + * verified quotes (INV-AH); the digest is never cited (INV-DIG1, radar only). + */ + +import { useState } from "react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + useCitationVerification, + useVerifyCitation, + type ArgumentBlock, + type SupportingPrecedent, + type RadarLead, +} from "@/lib/api/citation-verification"; + +const PRIORITY_LABEL: Record = { + threshold: "סף", + substantive: "מהותית", + procedural: "פרוצדורלית", + relief: "סעד", +}; + +const RADAR_LABEL: Record = { + new_lead: "ליד חדש", + gap_open: "בתור-חסרים", + fetched: "נמשך", + available_link: "בקורפוס — לקשר", +}; + +export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) { + const { data, isPending, isError } = useCitationVerification(caseNumber); + const verify = useVerifyCitation(caseNumber); + const [notes, setNotes] = useState>({}); + + if (isPending) { + return ( + + + + + + + + ); + } + if (isError || !data || data.status !== "ok") { + return ( + + + לא ניתן לטעון את אימות-הפסיקה כעת. + + + ); + } + if (!data.arguments.length) { + return ( + + +
+

אין עדיין סוגיות מזוקקות לתיק

+

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

+
+
+ ); + } + + function runVerify(a: ArgumentBlock, s: SupportingPrecedent, verified: boolean) { + verify.mutate( + { + argument_id: a.argument_id, + case_law_id: s.case_law_id, + quote: s.quote, + citation: s.case_number || s.case_name, + chair_note: notes[s.case_law_id] ?? s.chair_note, + verified, + attached_id: s.attached_id ?? "", + }, + { + onSuccess: () => toast.success(verified ? "הציטוט אומת" : "סומן כלא-רלוונטי"), + onError: (e: Error) => toast.error(`שגיאה: ${e.message}`), + }, + ); + } + + function saveNote(a: ArgumentBlock, s: SupportingPrecedent) { + const note = notes[s.case_law_id]; + if (note === undefined || note === s.chair_note) return; + verify.mutate( + { + argument_id: a.argument_id, + case_law_id: s.case_law_id, + quote: s.quote, + citation: s.case_number || s.case_name, + chair_note: note, + verified: s.verified, + attached_id: s.attached_id ?? "", + }, + { + onSuccess: () => toast.success("הערת-יו״ר נשמרה"), + onError: (e: Error) => toast.error(`שגיאה: ${e.message}`), + }, + ); + } + + const sum = data.summary; + + return ( +
+ {/* summary */} +
+ סוגיות עם פסיקה: {sum.arguments_with_support}/{sum.arguments_total} + אומתו: {sum.verified} + לידֵי רדאר: {sum.radar_leads} + ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1) +
+ + {data.arguments.map((a) => ( + + + {/* issue header */} +
+
+
{a.title}
+
+ סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""} +
+
+ {a.priority && ( + + {PRIORITY_LABEL[a.priority] ?? a.priority} + + )} +
+ + {/* supporting precedents */} +
+
פסיקה תומכת בקורפוס
+ {a.supporting.length === 0 ? ( +

לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.

+ ) : ( +
    + {a.supporting.map((s) => ( +
  • +
    + + {s.case_number || s.case_name} + + {s.cited_by.positive > 0 && ( + + אומץ ×{s.cited_by.positive} + + )} + {s.cited_by.negative > 0 && ( + + אובחן ×{s.cited_by.negative} + + )} +
    + {s.quote && ( +
    + {s.quote} +
    + )} +
    + {s.verified ? ( + <> + ✓ אומת ע״י היו״ר + + + ) : ( + <> + + + + )} + {s.cited_by.negative > 0 && !s.verified && ( + ⚠ אובחן — בדוק הקשר + )} +
    + {/* chair note */} +
    + + setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))} + onBlur={() => saveNote(a, s)} + className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold" + /> +
    +
  • + ))} +
+ )} +
+ + {/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */} + +
+
+ ))} +
+ ); +} + +function RadarStrip({ radar }: { radar: RadarLead[] }) { + if (!radar.length) return null; + return ( +
+
+ 📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט) +
+
    + {radar.map((r) => ( +
  • + {r.underlying_citation || "(אין מראה-מקום)"} + + {RADAR_LABEL[r.action] ?? r.action} + + {r.headline} +
  • + ))} +
+
+ ); +} diff --git a/web-ui/src/lib/api/citation-verification.ts b/web-ui/src/lib/api/citation-verification.ts new file mode 100644 index 0000000..c22468e --- /dev/null +++ b/web-ui/src/lib/api/citation-verification.ts @@ -0,0 +1,103 @@ +/** + * Citation-verification domain (X11 Phase 2 / #154) — the compose "אימות פסיקה" tab. + * + * Per legal argument: in-corpus supporting precedents with the cumulative authority + * signal (cited_by — followed/distinguished), their verify state + chair note, and + * per-issue radar (unlinked digests). The chair verifies before the writer cites + * (INV-AH). Backed by GET/POST /api/cases/{n}/citation-verification[/verify]. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "./client"; + +export type CitedBy = { + total: number; + positive: number; + negative: number; + unclassified: number; + by_treatment: Record; +}; + +export type SupportingPrecedent = { + case_law_id: string; + case_number: string; + case_name: string; + quote: string; + score: number; + cited_by: CitedBy; + attached_id: string | null; + verified: boolean; + chair_note: string; +}; + +export type RadarLead = { + digest_id: string; + yomon_number?: string | number | null; + headline: string; + underlying_citation: string; + underlying_court: string; + score: number; + missing_precedent_id: string | null; + missing_precedent_status: string | null; + action: "new_lead" | "gap_open" | "fetched" | "available_link" | string; + matched_issues?: string[]; +}; + +export type ArgumentBlock = { + argument_id: string; + title: string; + legal_topic: string; + priority: string; + party: string; + supporting: SupportingPrecedent[]; + radar: RadarLead[]; +}; + +export type CitationVerificationView = { + status: string; + case_number: string; + arguments: ArgumentBlock[]; + summary: { + arguments_total: number; + arguments_with_support: number; + verified: number; + radar_leads: number; + }; +}; + +export type VerifyCitationBody = { + argument_id: string; + case_law_id?: string; + quote?: string; + citation?: string; + chair_note?: string | null; + verified: boolean; + attached_id?: string; +}; + +export function useCitationVerification(caseNumber: string | undefined) { + return useQuery({ + queryKey: ["citation-verification", caseNumber ?? ""], + queryFn: ({ signal }) => + apiRequest( + `/api/cases/${caseNumber}/citation-verification`, + { signal }, + ), + enabled: Boolean(caseNumber), + staleTime: 10_000, + }); +} + +export function useVerifyCitation(caseNumber: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: VerifyCitationBody) => + apiRequest(`/api/cases/${caseNumber}/citation-verification/verify`, { + method: "POST", + body, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["citation-verification", caseNumber ?? ""] }); + }, + }); +}