Merge pull request 'feat(nav): הסרת דף מדגם-זהב (goldset) מה-UI' (#150) from worktree-rm-goldset into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 40s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 40s
This commit was merged in pull request #150.
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
* מרכז אישורים — דפנה (INV-G10).
|
||||
*
|
||||
* עמוד אחד שמרכז את כל השערים האנושיים הממתינים להכרעת היו"ר: אישור הלכות,
|
||||
* פסיקה חסרה, הערות שטרם יושמו, תיקים שנכשלו ב-QA, וסקירת gold-set. המטרה:
|
||||
* פסיקה חסרה, הערות שטרם יושמו, ותיקים שנכשלו ב-QA. המטרה:
|
||||
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
|
||||
*/
|
||||
const SEVERITY_BADGE: Record<ApprovalSeverity, string> = {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { GoldsetPanel } from "@/components/goldset/goldset-panel";
|
||||
|
||||
/**
|
||||
* Gold-set tagging page (#81.7 / #81.8).
|
||||
*
|
||||
* Interactive review of a stratified halacha sample. The chair/Dafna labels each
|
||||
* item (is_holding / correct_type / quote_complete); those human labels are the
|
||||
* ground truth that measures the extraction validators and recalibrates the
|
||||
* auto-approve threshold. Tags MUST be human — no AI pre-fill (circular bias).
|
||||
*/
|
||||
export default function GoldsetPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">מדגם-זהב לתיוג</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">מדגם-זהב לתיוג איכות</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
מדגם מרובד של הלכות שחולצו. לכל הלכה הכריעו שלוש שאלות —
|
||||
<strong> האם זו הלכה אמיתית</strong>, <strong>מה הסוג הנכון</strong>,
|
||||
ו<strong>האם הציטוט שלם</strong>. ההכרעות שלכם הן אמת-המידה שמודדת את
|
||||
דיוק המחלץ ומכיילת את סף-האישור האוטומטי. שיפוט משפטי אנושי בלבד —
|
||||
לא תיוג-AI (כדי למנוע הטיה מעגלית).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<GoldsetPanel />
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -56,8 +56,7 @@ const KNOWLEDGE_MENUS: NavMenuDef[] = [
|
||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||
{ href: "/digests", label: "יומונים" },
|
||||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||
{ href: "/graph", label: "מפת הקורפוס", groupLabel: "ניתוח וכיול" },
|
||||
{ href: "/goldset", label: "מדגם-זהב" },
|
||||
{ href: "/graph", label: "מפת הקורפוס" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
"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";
|
||||
import { AuthorityBadge } from "@/components/precedents/halacha-meta";
|
||||
|
||||
// rule ROLE only (INV-DM7) — authority (binding/persuasive) is a SEPARATE,
|
||||
// derived axis, shown read-only and never tagged here.
|
||||
const TYPES: { value: string; label: string }[] = [
|
||||
{ value: "holding", label: "מהותי" },
|
||||
{ value: "interpretive", label: "פרשני" },
|
||||
{ value: "procedural", label: "פרוצדורלי" },
|
||||
{ value: "application", label: "יישום" },
|
||||
{ value: "obiter", label: "אמרת-אגב" },
|
||||
];
|
||||
|
||||
// Consistency between is_holding and the role (#81.7): a real holding is
|
||||
// holding/interpretive/procedural; a NON-holding is its reason —
|
||||
// application (fact-bound) or obiter (not decided). Other pairings contradict.
|
||||
const HOLDING_TYPES = new Set(["holding", "interpretive", "procedural"]);
|
||||
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) ────────────────────────────────────────────
|
||||
|
||||
// Role only — "כמה מחייב" (מחייב/משכנע) is the SEPARATE authority axis, derived
|
||||
// automatically from the court's identity and shown as a read-only badge.
|
||||
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
|
||||
{
|
||||
label: "מהותי",
|
||||
def: "העיקרון המהותי שהיה הכרחי להכרעה — ה-ratio האמיתי. בר-הסתמכות מלא.",
|
||||
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מהותי.",
|
||||
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
|
||||
},
|
||||
{
|
||||
label: "פרשני",
|
||||
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
|
||||
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
|
||||
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
|
||||
},
|
||||
{
|
||||
label: "פרוצדורלי",
|
||||
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
|
||||
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
|
||||
example: "המועד להגשת ערר הוא 30 יום.",
|
||||
},
|
||||
{
|
||||
label: "יישום",
|
||||
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
|
||||
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
|
||||
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
|
||||
},
|
||||
{
|
||||
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>
|
||||
<AuthorityBadge authority={it.authority} />
|
||||
{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">
|
||||
“{it.supporting_quote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { apiRequest } from "./client";
|
||||
|
||||
/**
|
||||
* Chair approval center (INV-G10) — aggregates every pending human-gate item
|
||||
* (halacha approvals, missing precedents, unapplied feedback, QA-failed cases,
|
||||
* gold-set review) so nothing Dafna must approve is forgotten.
|
||||
* (halacha approvals, missing precedents, unapplied feedback, QA-failed cases)
|
||||
* so nothing Dafna must approve is forgotten.
|
||||
*
|
||||
* Hand-typed (not from the generated types.ts) because /api/chair/pending is a
|
||||
* new endpoint; switch to the generated type after the next `npm run api:types`.
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Gold-set tagging API (#81.7 / #81.8).
|
||||
*
|
||||
* The chair/Dafna manually labels a stratified sample of halachot
|
||||
* (is_holding / correct_type / quote_complete). Those human labels are the
|
||||
* ground truth used to measure the extraction validators and recalibrate the
|
||||
* auto-approve threshold. Endpoints under /api/goldset.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type GoldsetItem = {
|
||||
id: string;
|
||||
halacha_id: string;
|
||||
// human tags (null until tagged)
|
||||
is_holding: boolean | null;
|
||||
correct_type: string;
|
||||
quote_complete: boolean | null;
|
||||
tagged_by: string;
|
||||
tagged_at: string | null;
|
||||
// halacha content + the machine's own labels
|
||||
rule_statement: string;
|
||||
supporting_quote: string;
|
||||
reasoning_summary: string;
|
||||
rule_type: string;
|
||||
// authority over the committee — DERIVED from the source (INV-DM7), read-only.
|
||||
authority?: "binding" | "persuasive" | null;
|
||||
confidence: number | null;
|
||||
quality_flags?: string[];
|
||||
review_status: string;
|
||||
case_number: string | null;
|
||||
case_name: string | null;
|
||||
source_type: string | null; // 'court_ruling' | 'appeals_committee' | ''
|
||||
// AI second-opinion (QA aid — independent, not ground truth, not auto-applied)
|
||||
ai_is_holding: boolean | null;
|
||||
ai_correct_type: string;
|
||||
ai_rationale: string;
|
||||
ai_generated_at: string | null;
|
||||
};
|
||||
|
||||
export type GoldsetScore = {
|
||||
batch: string;
|
||||
total: number;
|
||||
labeled: number;
|
||||
validators: Record<
|
||||
string,
|
||||
{ precision: number; recall: number; f1: number; tp: number; fp: number; fn: number; tn: number }
|
||||
>;
|
||||
};
|
||||
|
||||
export type GoldsetTag = {
|
||||
is_holding?: boolean | null;
|
||||
correct_type?: string;
|
||||
quote_complete?: boolean | null;
|
||||
};
|
||||
|
||||
const keys = {
|
||||
all: ["goldset"] as const,
|
||||
list: (batch: string) => ["goldset", "list", batch] as const,
|
||||
score: (batch: string) => ["goldset", "score", batch] as const,
|
||||
};
|
||||
|
||||
export function useGoldset(batch = "default") {
|
||||
return useQuery({
|
||||
queryKey: keys.list(batch),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{ items: GoldsetItem[]; batch: string }>(
|
||||
`/api/goldset?batch=${encodeURIComponent(batch)}`,
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
}
|
||||
|
||||
export function useGoldsetScore(batch = "default") {
|
||||
return useQuery({
|
||||
queryKey: keys.score(batch),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<GoldsetScore>(
|
||||
`/api/goldset/score?batch=${encodeURIComponent(batch)}`,
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTagGoldset(batch = "default") {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, tag }: { id: string; tag: GoldsetTag }) =>
|
||||
apiRequest<{ ok: boolean }>(`/api/goldset/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: { ...tag, tagged_by: "chair" },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.list(batch) });
|
||||
qc.invalidateQueries({ queryKey: keys.score(batch) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGoldsetSample(batch = "default") {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (n: number) =>
|
||||
apiRequest<{ batch: string; inserted: number; total: number }>(
|
||||
"/api/goldset/sample",
|
||||
{ method: "POST", body: { n, batch } },
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.list(batch) }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user