Files
legal-ai/web-ui/src/components/compose/citation-verification-panel.tsx
Chaim 5108c854cf
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(citation-verify): frontend "אימות פסיקה" tab in the decision editor (#154)
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) <noreply@anthropic.com>
2026-06-20 18:19:03 +00:00

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> = {
threshold: "סף",
substantive: "מהותית",
procedural: "פרוצדורלית",
relief: "סעד",
};
const RADAR_LABEL: Record<string, string> = {
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<Record<string, string>>({});
if (isPending) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
);
}
if (isError || !data || data.status !== "ok") {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-8 text-center text-ink-muted">
לא ניתן לטעון את אימות-הפסיקה כעת.
</CardContent>
</Card>
);
}
if (!data.arguments.length) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-2">
<div className="text-gold text-3xl" aria-hidden></div>
<p className="text-navy font-semibold mb-0">אין עדיין סוגיות מזוקקות לתיק</p>
<p className="text-ink-muted text-sm">
הרץ את ניתוח-הטענות (aggregate) כדי שהמערכת תזהה סוגיות ותתאים להן פסיקה.
</p>
</CardContent>
</Card>
);
}
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 (
<div className="space-y-4">
{/* summary */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-ink-soft bg-parchment border border-rule rounded-lg px-4 py-2.5">
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
</div>
{data.arguments.map((a) => (
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
<CardContent className="p-0">
{/* issue header */}
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
<div className="min-w-0 flex-1">
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
<div className="text-ink-muted text-xs mt-0.5">
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
</div>
</div>
{a.priority && (
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.68rem] font-semibold px-2.5 py-0.5">
{PRIORITY_LABEL[a.priority] ?? a.priority}
</span>
)}
</div>
{/* supporting precedents */}
<div className="px-5 py-3">
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">פסיקה תומכת בקורפוס</div>
{a.supporting.length === 0 ? (
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
) : (
<ul className="list-none p-0 m-0 divide-y divide-rule-soft">
{a.supporting.map((s) => (
<li key={s.case_law_id} className="py-3 first:pt-1">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/precedents/${s.case_law_id}`}
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
>
{s.case_number || s.case_name}
</a>
{s.cited_by.positive > 0 && (
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
אומץ ×{s.cited_by.positive}
</span>
)}
{s.cited_by.negative > 0 && (
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
אובחן ×{s.cited_by.negative}
</span>
)}
</div>
{s.quote && (
<blockquote className="border-s-[3px] border-gold bg-gold-wash text-ink-soft text-sm leading-7 rounded-e-md px-3.5 py-2 my-2 mx-0">
{s.quote}
</blockquote>
)}
<div className="flex items-center gap-2 flex-wrap">
{s.verified ? (
<>
<span className="text-success text-xs font-semibold"> אומת ע״י היו״ר</span>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, false)}
className="text-xs text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
>
בטל אימות
</button>
</>
) : (
<>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, true)}
className="text-xs font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
>
מאמת
</button>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, false)}
className="text-xs font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
>
לא רלוונטי
</button>
</>
)}
{s.cited_by.negative > 0 && !s.verified && (
<span className="text-warn text-[0.72rem] ms-1"> אובחן בדוק הקשר</span>
)}
</div>
{/* chair note */}
<div className="mt-2 flex items-start gap-2">
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
הערת יו״ר:
</label>
<input
type="text"
defaultValue={s.chair_note}
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
onChange={(e) => 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"
/>
</div>
</li>
))}
</ul>
)}
</div>
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
<RadarStrip radar={a.radar} />
</CardContent>
</Card>
))}
</div>
);
}
function RadarStrip({ radar }: { radar: RadarLead[] }) {
if (!radar.length) return null;
return (
<div className="mx-5 mb-4 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
<div className="text-[0.72rem] font-bold text-gold-deep mb-1">
📡 רדאר פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
</div>
<ul className="list-none p-0 m-0 space-y-1">
{radar.map((r) => (
<li key={r.digest_id} className="flex items-center gap-2 flex-wrap text-[0.8rem]">
<span className="font-semibold text-navy">{r.underlying_citation || "(אין מראה-מקום)"}</span>
<span className="rounded-full bg-warn-bg text-warn text-[0.64rem] font-semibold px-2 py-0.5">
{RADAR_LABEL[r.action] ?? r.action}
</span>
<span className="text-ink-muted truncate">{r.headline}</span>
</li>
))}
</ul>
</div>
);
}