Files
legal-ai/web-ui/src/components/compose/citation-verification-panel.tsx
Chaim 72f9bbb77c feat(case-ui): redesign אימות-פסיקה — progress + filters + accordion (X17 18g)
Summary with a verification progress bar, status filter chips + only-unverified
toggle, per-argument collapsible accordion with a v/t verify-progress chip, and
compact precedent rows (clamped/expandable quote, collapsible chair note). All
verify/saveNote mutations + types preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:57:58 +00:00

433 lines
19 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).
*
* Redesign (mockup 18g): summary + verification progress bar, status filters,
* collapsible per-argument accordion with a verify-progress chip, and compact
* precedent rows (clamped quote, collapsible chair note) — easier to triage.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
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: "בקורפוס — לקשר",
};
type FilterKey = "all" | "pending" | "verified" | "radar";
const FILTERS: { key: FilterKey; label: string }[] = [
{ key: "all", label: "הכל" },
{ key: "pending", label: "ממתין לאימות" },
{ key: "verified", label: "מאומת" },
{ key: "radar", label: "עם רדאר" },
];
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
const { data, isPending, isError } = useCitationVerification(caseNumber);
const verify = useVerifyCitation(caseNumber);
const [notes, setNotes] = useState<Record<string, string>>({});
const [openNotes, setOpenNotes] = useState<Set<string>>(new Set());
const [expandedQuotes, setExpandedQuotes] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<FilterKey>("all");
const [onlyUnverified, setOnlyUnverified] = useState(false);
function toggleSet(
setter: React.Dispatch<React.SetStateAction<Set<string>>>,
id: string,
) {
setter((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
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;
const args = data.arguments;
// verification progress across all supporting precedents
let totalSup = 0;
let verifiedSup = 0;
for (const a of args) {
totalSup += a.supporting.length;
verifiedSup += a.supporting.filter((s) => s.verified).length;
}
const pendingSup = totalSup - verifiedSup;
const okPct = totalSup ? Math.round((verifiedSup / totalSup) * 100) : 0;
const supPct = totalSup ? Math.round((pendingSup / totalSup) * 100) : 0;
const noSupportCount = sum.arguments_total - sum.arguments_with_support;
const argVerified = (a: ArgumentBlock) =>
a.supporting.filter((s) => s.verified).length;
const argHasPending = (a: ArgumentBlock) =>
a.supporting.some((s) => !s.verified);
const shownArgs = args.filter((a) => {
if (filter === "pending") return argHasPending(a);
if (filter === "verified") return argVerified(a) > 0;
if (filter === "radar") return a.radar.length > 0;
return true;
});
// default-open the actionable arguments (those with unverified support)
const defaultOpen = args.filter(argHasPending).map((a) => a.argument_id);
return (
<div className="space-y-4">
{/* summary + progress */}
<div className="rounded-xl border border-rule bg-surface shadow-sm px-5 py-4">
<div className="flex items-center gap-6 flex-wrap">
<Stat k="סוגיות עם פסיקה" v={`${sum.arguments_with_support}/${sum.arguments_total}`} />
<Stat k="אומתו" v={String(sum.verified)} tone="text-success" />
<Stat k="לידֵי רדאר" v={String(sum.radar_leads)} tone="text-warn" />
<span className="ms-auto text-[0.72rem] text-ink-muted max-w-[280px] leading-snug">
ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)
</span>
</div>
<div className="mt-3.5">
<div className="h-2 rounded-full bg-rule-soft overflow-hidden flex">
<span className="bg-success h-full" style={{ width: `${okPct}%` }} />
<span className="bg-gold-soft h-full" style={{ width: `${supPct}%` }} />
</div>
<div className="flex gap-4 mt-1.5 text-[0.7rem] text-ink-muted flex-wrap">
<span><Dot className="bg-success" /><b className="text-ink-soft">{verifiedSup} מאומתים</b></span>
<span><Dot className="bg-gold-soft" /><b className="text-ink-soft">{pendingSup} ממתינים לאימות</b></span>
<span><Dot className="bg-rule-soft" />{noSupportCount} סוגיות ללא פסיקה תומכת</span>
</div>
</div>
</div>
{/* filters */}
<div className="flex items-center gap-2 flex-wrap">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={`text-[0.78rem] font-semibold rounded-full px-3.5 py-1.5 border transition-colors ${
filter === f.key
? "bg-navy text-parchment border-transparent"
: "bg-surface text-ink-soft border-rule hover:bg-parchment"
}`}
>
{f.label}
</button>
))}
<label className="ms-auto flex items-center gap-2 text-[0.78rem] text-ink-soft font-medium cursor-pointer select-none">
רק לא-מאומתים
<input
type="checkbox"
checked={onlyUnverified}
onChange={(e) => setOnlyUnverified(e.target.checked)}
className="accent-gold w-4 h-4"
/>
</label>
</div>
{/* arguments */}
<Accordion type="multiple" defaultValue={defaultOpen} className="space-y-2.5">
{shownArgs.map((a) => {
const v = argVerified(a);
const t = a.supporting.length;
const tone =
t === 0 || v === 0
? "bg-rule-soft text-ink-muted"
: v === t
? "bg-success-bg text-success"
: "bg-warn-bg text-warn";
const supShown = onlyUnverified
? a.supporting.filter((s) => !s.verified)
: a.supporting;
return (
<AccordionItem
key={a.argument_id}
value={a.argument_id}
className="border border-rule rounded-xl bg-surface shadow-sm overflow-hidden data-[state=open]:border-gold-soft"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline gap-3">
<span className="flex flex-1 items-center gap-3 min-w-0">
<span className="flex flex-col items-start min-w-0 text-start">
<span className="text-navy font-bold text-[0.92rem] truncate max-w-full">
{a.title}
</span>
<span className="text-ink-muted text-[0.72rem]">
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
</span>
</span>
{a.priority && (
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.66rem] font-semibold px-2.5 py-0.5">
{PRIORITY_LABEL[a.priority] ?? a.priority}
</span>
)}
<span className={`shrink-0 rounded-full text-[0.7rem] font-bold px-2.5 py-0.5 tabular-nums ${tone}`}>
{v}/{t}
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-4 pb-3 pt-0">
<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>
) : supShown.length === 0 ? (
<p className="text-ink-muted text-sm py-2">
כל הפסיקה לסוגיה זו אומתה.
</p>
) : (
<ul className="list-none p-0 m-0">
{supShown.map((s) => {
const noteOpen =
openNotes.has(s.case_law_id) || !!s.chair_note;
const quoteExpanded = expandedQuotes.has(s.case_law_id);
return (
<li
key={s.case_law_id}
className="py-3 border-t border-rule-soft first:border-t-0"
>
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/precedents/${s.case_law_id}`}
className="font-bold text-gold-deep text-[0.85rem] 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.66rem] 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.66rem] font-semibold px-2 py-0.5">
אובחן ×{s.cited_by.negative}
</span>
)}
<span className="ms-auto flex items-center gap-1.5">
{s.verified ? (
<>
<span className="text-success text-[0.72rem] font-semibold">
אומת ע״י היו״ר
</span>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, false)}
className="text-[0.72rem] 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-[0.72rem] 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-[0.72rem] font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
>
לא רלוונטי
</button>
</>
)}
</span>
</div>
{s.quote && (
<blockquote
onClick={() => toggleSet(setExpandedQuotes, s.case_law_id)}
title="לחצי להרחבה/כיווץ"
className={`mt-2 cursor-pointer border-s-[3px] border-gold bg-gold-wash text-ink-soft text-[0.82rem] leading-6 rounded-e-md px-3 py-1.5 ${quoteExpanded ? "" : "line-clamp-2"}`}
>
{s.quote}
</blockquote>
)}
{s.cited_by.negative > 0 && !s.verified && (
<div className="text-warn text-[0.72rem] mt-1.5"> אובחן בדוק הקשר</div>
)}
{noteOpen ? (
<div className="mt-2 flex items-center gap-2">
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap">
הערת יו״ר:
</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>
) : (
<button
type="button"
onClick={() => toggleSet(setOpenNotes, s.case_law_id)}
className="mt-2 text-[0.72rem] text-gold-deep font-semibold hover:underline"
>
הוסף הערת יו״ר
</button>
)}
</li>
);
})}
</ul>
)}
<RadarStrip radar={a.radar} />
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
);
}
function Stat({ k, v, tone }: { k: string; v: string; tone?: string }) {
return (
<span className="flex flex-col">
<span className="text-[0.72rem] text-ink-muted">{k}</span>
<span className={`text-lg font-bold tabular-nums mt-0.5 ${tone ?? "text-navy"}`}>{v}</span>
</span>
);
}
function Dot({ className }: { className: string }) {
return <span className={`inline-block w-2 h-2 rounded-sm me-1 align-middle ${className}`} />;
}
function RadarStrip({ radar }: { radar: RadarLead[] }) {
if (!radar.length) return null;
return (
<div className="mt-3 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>
);
}