Files
legal-ai/web-ui/src/components/goldset/goldset-panel.tsx
Chaim 90f3c472b5 fix(goldset): single view-mode filter — can't get stuck hiding untagged
The old independent toggles had a trap: clicking "אי-הסכמות AI" set a filter,
and once all disagreements were resolved the toggle button disappeared
(rendered only when count>0) while the filter stayed ON — so the list showed
zero items and the untagged ones were unreachable.

Replaced hideTagged + disagreeOnly with one mutually-exclusive segmented
control: הכל / לא תויגו / תויגו / ⚠ אי-הסכמות, each with a live count and always
visible. No stuck state; "לא תויגו" makes the remaining work obvious.

Verified: tsc --noEmit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:47:53 +00:00

502 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { useEffect, useMemo, useState } from "react";
import { Check, X, ChevronDown, ChevronLeft, Info, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
type GoldsetItem,
} from "@/lib/api/goldset";
const TYPES: { value: string; label: string }[] = [
{ value: "binding", label: "מחייבת" },
{ value: "interpretive", label: "פרשני" },
{ value: "application", label: "יישום" },
{ value: "obiter", label: "אמרת-אגב" },
{ value: "procedural", label: "פרוצדורלי" },
{ value: "persuasive", label: "משכנע" },
];
// Consistency between is_holding and the type (#81.7): a real holding is
// binding/interpretive/procedural/persuasive; a NON-holding is its reason —
// application (fact-bound) or obiter (not decided). Other pairings contradict.
const HOLDING_TYPES = new Set(["binding", "interpretive", "procedural", "persuasive"]);
const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
function inconsistentTag(it: GoldsetItem): string | null {
if (it.is_holding === null || !it.correct_type) return null;
if (it.is_holding === true && NON_HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
}
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
}
return null;
}
const FLAG_LABELS: Record<string, string> = {
non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר", application: "יישום",
near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו",
};
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
// Source separation (פסקי-דין מול החלטות ועדת-ערר) for convenient tagging.
function sourceLabel(s: string | null): string {
return s === "court_ruling" ? "פסק-דין"
: s === "appeals_committee" ? "ועדת ערר" : "אחר";
}
const SOURCE_FILTERS: { value: "all" | "court_ruling" | "appeals_committee"; label: string }[] = [
{ value: "all", label: "הכל" },
{ value: "court_ruling", label: "פסקי דין" },
{ value: "appeals_committee", label: "ועדת ערר" },
];
function isTagged(it: GoldsetItem): boolean {
// Fully tagged only when ALL THREE answers are set — otherwise, in
// "hide tagged" mode, a card would vanish the moment is_holding is clicked,
// before correct_type / quote_complete can be set.
return it.is_holding !== null && it.quote_complete !== null && !!it.correct_type;
}
// The AI second-opinion disagrees with the human tag (on is_holding or type).
function aiDisagrees(it: GoldsetItem): boolean {
if (!it.ai_generated_at) return false;
const holdDiff = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDiff = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
return holdDiff || typeDiff;
}
// ─── Score panel ──────────────────────────────────────────────────────────────
function ScorePanel({ batch }: { batch: string }) {
const { data } = useGoldsetScore(batch);
const [open, setOpen] = useState(true);
if (!data || data.labeled === 0) return null;
const rows = Object.entries(data.validators);
// negatives so far (truly "not a holding") = tp+fn of any validator.
const af = data.validators.any_flag;
const negatives = af ? af.tp + af.fn : 0;
return (
<div className="rounded-lg border border-rule bg-surface">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gold-wash/30"
aria-expanded={open}
>
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
<span className="font-semibold text-navy">ציון הוולידטורים מול התיוג</span>
<span className="text-ink-muted">({data.labeled} מתויגות)</span>
</button>
{open && (
<div className="px-4 pb-3 overflow-x-auto">
<p className="text-[0.72rem] text-ink-muted mb-2">
המדדים מודדים זיהוי <strong>"לא-הלכה"</strong> (יישום / ציטוט-קטוע / אי-הכרעה...).
{negatives < 10
? ` עד כה תויגו רק ${negatives} פריטי "לא הלכה" — המספרים יהפכו משמעותיים ככל שיצטברו עוד (במיוחד מבקט המסומנים).`
: " precision גבוה = מעט אזעקות-שווא; recall גבוה = תופס את רוב ה'לא-הלכה'."}
</p>
<table className="w-full text-sm tabular-nums">
<thead>
<tr className="text-ink-muted text-[0.72rem] border-b border-rule">
<th className="text-start py-1 ps-1">ולידטור</th>
<th className="text-start">Precision</th>
<th className="text-start">Recall</th>
<th className="text-start">F1</th>
<th className="text-start">tp/fp/fn/tn</th>
</tr>
</thead>
<tbody>
{rows.map(([name, v]) => (
<tr key={name} className="border-b border-rule-soft last:border-0">
<td className="py-1 ps-1 text-navy">{name}</td>
<td>{v.precision.toFixed(2)}</td>
<td>{v.recall.toFixed(2)}</td>
<td className="font-semibold">{v.f1.toFixed(2)}</td>
<td className="text-ink-muted text-[0.72rem]">
{v.tp}/{v.fp}/{v.fn}/{v.tn}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Rule-type help (info popover) ────────────────────────────────────────────
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
{
label: "מחייבת",
def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.",
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.",
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
},
{
label: "פרשני",
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
},
{
label: "יישום",
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
},
{
label: "אמרת-אגב",
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.",
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
},
{
label: "פרוצדורלי",
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
example: "המועד להגשת ערר הוא 30 יום.",
},
{
label: "משכנע",
def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.",
test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.",
example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.",
},
];
function RuleTypeHelp() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center text-ink-muted hover:text-gold-deep"
aria-label="הסבר על סוגי ההלכה"
title="הסבר על הסוגים"
>
<Info className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(92vw,560px)] max-h-[70vh] overflow-y-auto p-0">
<div className="p-3 border-b border-rule">
<p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p>
<p className="text-[0.72rem] text-ink-muted mt-0.5">
כלל-אצבע: סימנת "הלכה" לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" לרוב יישום / אמרת-אגב.
</p>
</div>
<ul className="divide-y divide-rule-soft">
{TYPE_HELP.map((t) => (
<li key={t.label} className="p-3 space-y-1" dir="rtl">
<div className="font-semibold text-navy text-sm">{t.label}</div>
<div className="text-[0.78rem] text-ink-soft leading-relaxed">{t.def}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">מבחן:</span> {t.test}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">דוגמה:</span> {t.example}</div>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
// ─── Tag card ─────────────────────────────────────────────────────────────────
function TagCard({
it, focused, onTag,
}: {
it: GoldsetItem;
focused: boolean;
onTag: (tag: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean }) => void;
}) {
const tagged = isTagged(it);
return (
<div
data-goldset-id={it.id}
className={`rounded-lg border bg-surface p-4 space-y-3 transition-colors
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : tagged ? "border-rule-soft opacity-70" : "border-rule"}`}
>
<div className="flex items-center gap-2 text-[0.72rem] text-ink-muted flex-wrap">
<span className="font-semibold text-navy">{cleanCitation(it.case_number)}</span>
<Badge variant="outline"
className={`text-[0.65rem] ${it.source_type === "court_ruling"
? "bg-navy-soft/30 text-navy border-navy/30"
: "bg-gold-wash text-gold-deep border-gold/40"}`}>
{sourceLabel(it.source_type)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
{it.confidence != null && (
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
)}
{(it.quality_flags ?? []).map((f) => (
<Badge key={f} variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
{FLAG_LABELS[f] ?? f}
</Badge>
))}
{tagged && (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40 ms-auto">
<Check className="w-3 h-3 me-1" /> תויג
</Badge>
)}
</div>
<p className="text-navy font-medium leading-relaxed" dir="rtl">{it.rule_statement}</p>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{it.supporting_quote}&rdquo;
</blockquote>
{it.ai_generated_at && (() => {
const aiType = TYPES.find((t) => t.value === it.ai_correct_type)?.label ?? it.ai_correct_type;
const holdDisagree = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDisagree = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
const anyTag = it.is_holding !== null || !!it.correct_type;
return (
<div className={`rounded-md border p-2.5 text-[0.78rem] space-y-1
${holdDisagree ? "border-amber-400 bg-amber-50" : "border-rule bg-rule-soft/20"}`} dir="rtl">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-navy">🤖 המלצת AI:</span>
<span>{it.ai_is_holding ? "הלכה" : "לא הלכה"}</span>
{aiType && <span className="text-ink-muted">· {aiType}</span>}
{anyTag && (
<span className={`ms-auto text-[0.7rem] px-1.5 py-0.5 rounded
${holdDisagree || typeDisagree
? "bg-amber-100 text-amber-800"
: "bg-emerald-50 text-emerald-700"}`}>
{holdDisagree ? "⚠ חולק על 'הלכה/לא'"
: typeDisagree ? "⚠ חולק על הסוג"
: "✓ מסכים איתך"}
</span>
)}
</div>
{it.ai_rationale && <div className="text-ink-soft leading-relaxed">{it.ai_rationale}</div>}
</div>
);
})()}
<div className="grid gap-3 sm:grid-cols-3 pt-1 border-t border-rule-soft">
{/* is_holding */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">האם זו הלכה אמיתית?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.is_holding === true ? "default" : "ghost"}
className={it.is_holding === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ is_holding: true })}>הלכה (H)</Button>
<Button size="sm" variant={it.is_holding === false ? "default" : "ghost"}
className={it.is_holding === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ is_holding: false })}>לא (N)</Button>
</div>
</div>
{/* correct_type */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1 flex items-center gap-1">
הסוג הנכון
<RuleTypeHelp />
</div>
<div className="flex gap-1 flex-wrap">
{TYPES.map((t) => (
<Button key={t.value} size="sm"
variant={it.correct_type === t.value ? "default" : "ghost"}
className={`text-[0.7rem] px-2 ${it.correct_type === t.value ? "bg-navy text-parchment hover:bg-navy-soft" : ""}`}
onClick={() => onTag({ correct_type: t.value })}>{t.label}</Button>
))}
</div>
{inconsistentTag(it) && (
<p className="mt-1 text-[0.68rem] text-amber-700 flex items-start gap-1">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{inconsistentTag(it)}
</p>
)}
</div>
{/* quote_complete */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">הציטוט שלם?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.quote_complete === true ? "default" : "ghost"}
className={it.quote_complete === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ quote_complete: true })}>שלם (C)</Button>
<Button size="sm" variant={it.quote_complete === false ? "default" : "ghost"}
className={it.quote_complete === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ quote_complete: false })}>קטוע (X)</Button>
</div>
</div>
</div>
</div>
);
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function GoldsetPanel() {
const batch = "default";
const { data, isPending, error } = useGoldset(batch);
const tag = useTagGoldset(batch);
const createSample = useCreateGoldsetSample(batch);
const [focusedId, setFocusedId] = useState<string | null>(null);
// Single mutually-exclusive view mode — can't get "stuck" like the old
// independent toggles (where the disagree filter hid the untagged items).
const [viewMode, setViewMode] =
useState<"all" | "untagged" | "tagged" | "disagree">("all");
const [sourceFilter, setSourceFilter] =
useState<"all" | "court_ruling" | "appeals_committee">("all");
const items = useMemo(() => data?.items ?? [], [data]);
const taggedCount = items.filter(isTagged).length;
const untaggedCount = items.length - taggedCount;
const disagreeCount = items.filter(aiDisagrees).length;
const sourceCounts = useMemo(() => ({
court_ruling: items.filter((i) => i.source_type === "court_ruling").length,
appeals_committee: items.filter((i) => i.source_type === "appeals_committee").length,
}), [items]);
const visible = useMemo(() => {
let v = items;
if (sourceFilter !== "all") v = v.filter((i) => i.source_type === sourceFilter);
if (viewMode === "untagged") v = v.filter((i) => !isTagged(i));
else if (viewMode === "tagged") v = v.filter(isTagged);
else if (viewMode === "disagree") v = v.filter(aiDisagrees);
// group-sort: כל פסקי-הדין יחד, ואז כל החלטות ועדת-הערר (הפרדה ברורה).
const order = (s: string | null) =>
s === "court_ruling" ? 0 : s === "appeals_committee" ? 1 : 2;
return [...v].sort((a, b) => order(a.source_type) - order(b.source_type));
}, [items, viewMode, sourceFilter]);
const focused = focusedId ? visible.find((i) => i.id === focusedId) ?? null : null;
useEffect(() => {
if (focusedId && visible.some((i) => i.id === focusedId)) return;
setFocusedId(visible[0]?.id ?? null);
}, [focusedId, visible]);
useEffect(() => {
if (!focusedId) return;
document.querySelector(`[data-goldset-id="${focusedId}"]`)
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]);
const move = (delta: 1 | -1) => {
if (!visible.length) return;
const idx = focusedId ? visible.findIndex((i) => i.id === focusedId) : -1;
const next = idx < 0 ? (delta > 0 ? 0 : visible.length - 1)
: Math.max(0, Math.min(visible.length - 1, idx + delta));
setFocusedId(visible[next].id);
};
const doTag = async (
it: GoldsetItem,
t: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean },
) => {
try {
await tag.mutateAsync({ id: it.id, tag: t });
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tagName = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") return;
if (e.key === "j") { e.preventDefault(); move(1); }
else if (e.key === "k") { e.preventDefault(); move(-1); }
else if (focused && (e.key === "h" || e.key === "H")) { e.preventDefault(); doTag(focused, { is_holding: true }); }
else if (focused && (e.key === "n" || e.key === "N")) { e.preventDefault(); doTag(focused, { is_holding: false }); }
else if (focused && (e.key === "c" || e.key === "C")) { e.preventDefault(); doTag(focused, { quote_complete: true }); }
else if (focused && (e.key === "x" || e.key === "X")) { e.preventDefault(); doTag(focused, { quote_complete: false }); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visible]);
if (error) {
return <div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">{error.message}</div>;
}
if (isPending) {
return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}</div>;
}
if (!items.length) {
return (
<div className="text-center text-ink-muted py-16 space-y-4">
<p className="text-lg">אין מדגם-זהב עדיין.</p>
<Button disabled={createSample.isPending}
onClick={() => createSample.mutate(150)}
className="bg-gold text-navy hover:bg-gold-deep">
צור מדגם של 150 הלכות לתיוג
</Button>
</div>
);
}
const pct = items.length ? Math.round((taggedCount / items.length) * 100) : 0;
return (
<div className="space-y-4">
<ScorePanel batch={batch} />
{/* source separation — פסקי-דין מול החלטות ועדת-ערר */}
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
{SOURCE_FILTERS.map((s) => (
<Button key={s.value} size="sm"
variant={sourceFilter === s.value ? "default" : "ghost"}
className={sourceFilter === s.value ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setSourceFilter(s.value)}>
{s.label}
{s.value === "court_ruling" && ` (${sourceCounts.court_ruling})`}
{s.value === "appeals_committee" && ` (${sourceCounts.appeals_committee})`}
</Button>
))}
</div>
<div className="flex items-center gap-3 flex-wrap text-sm">
<span className="text-navy font-semibold tabular-nums">{taggedCount}/{items.length} תויגו</span>
<div className="h-2 w-40 rounded-full bg-rule-soft overflow-hidden">
<div className="h-full bg-gold" style={{ width: `${pct}%` }} />
</div>
<span className="text-ink-muted text-[0.72rem]">
מקלדת: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
{" "}· הלכה <kbd className="bg-rule-soft px-1.5 rounded">H</kbd> / לא <kbd className="bg-rule-soft px-1.5 rounded">N</kbd>
{" "}· ציטוט שלם <kbd className="bg-rule-soft px-1.5 rounded">C</kbd> / קטוע <kbd className="bg-rule-soft px-1.5 rounded">X</kbd>
</span>
<div className="ms-auto flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30">
{([
{ v: "all", label: `הכל (${items.length})` },
{ v: "untagged", label: `לא תויגו (${untaggedCount})` },
{ v: "tagged", label: `תויגו (${taggedCount})` },
{ v: "disagree", label: `⚠ אי-הסכמות (${disagreeCount})` },
] as const).map((m) => (
<Button key={m.v} size="sm"
variant={viewMode === m.v ? "default" : "ghost"}
className={viewMode === m.v
? (m.v === "disagree" ? "bg-amber-500 text-white hover:bg-amber-600" : "bg-gold text-navy hover:bg-gold-deep")
: (m.v === "disagree" ? "text-amber-700" : "")}
onClick={() => setViewMode(m.v)}>
{m.label}
</Button>
))}
</div>
</div>
<div className="space-y-3">
{visible.map((it) => (
<TagCard key={it.id} it={it} focused={it.id === focusedId}
onTag={(t) => doTag(it, t)} />
))}
</div>
</div>
);
}