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>
This commit is contained in:
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||||
|
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -276,6 +277,7 @@ export default function ComposePage({
|
|||||||
<TabsList className="bg-rule-soft/60">
|
<TabsList className="bg-rule-soft/60">
|
||||||
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
||||||
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
||||||
|
<TabsTrigger value="verify">אימות פסיקה</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
||||||
@@ -283,6 +285,11 @@ export default function ComposePage({
|
|||||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 3 — citation verification: per-argument support + verify gate (#154) */}
|
||||||
|
<TabsContent value="verify" className="mt-5">
|
||||||
|
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
||||||
<TabsContent value="positions" className="mt-5">
|
<TabsContent value="positions" className="mt-5">
|
||||||
{analysis.isPending ? (
|
{analysis.isPending ? (
|
||||||
|
|||||||
261
web-ui/src/components/compose/citation-verification-panel.tsx
Normal file
261
web-ui/src/components/compose/citation-verification-panel.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web-ui/src/lib/api/citation-verification.ts
Normal file
103
web-ui/src/lib/api/citation-verification.ts
Normal file
@@ -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<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CitationVerificationView>(
|
||||||
|
`/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 ?? ""] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user