feat(halacha-triage UI): wire gating + near-duplicate cluster cards (#84.2)
Completes #84 — surfaces the backend gating/prioritization (#84.1/#84.3, PR #93) in the chair's review UI and adds near-duplicate clustering (#84.2). Backend - db.list_halachot gains `cluster` (#84.2): annotates each row with cluster_id + cluster_size by unioning same-precedent halachot within HALACHA_CLUSTER_COSINE (0.90, new config). Display-only — never merges/deletes. Pairwise is confined to the returned set (cheap). - GET /api/halachot exposes the `cluster` query param (default off). Frontend (web-ui) - Halacha type gains optional cluster_id / cluster_size (hand-written module; no api:types regen needed — halachot aren't typed off the generated schema). - useHalachotPending(opts): the default "clean" queue now fetches exclude_low_quality + order_by_priority + cluster; needsFix:true returns the flagged 'needs extraction fix' bucket (filtered client-side). - HalachaReviewPanel: a "תור נקי / דורש תיקון-חילוץ" toggle (#84.1); near-dup clusters collapse into ONE card showing "+N וריאנטים" with an expandable list, and approve/reject/defer on a clustered card applies to all variants via the batch endpoint (#84.2 + #84.4). Counts show true halacha totals (pendingTotal). New flag labels added (application / near_duplicate / nevo_preamble_leak). Verified: - backend: list_halachot(cluster=True) on the live queue — algorithm correct (groups related same-precedent rules at 0.78; none at the production 0.90 because dedup #82 already removed near-dups — the desired state). - frontend: `tsc --noEmit` exits 0 (type-clean); no new lint errors (the one lint error is pre-existing in training/learning-panel.tsx from #94). Local Turbopack build can't run on the worktree node_modules symlink — CI builds in a clean checkout. Invariants: G1 (gate/cluster at source in SQL, not post-hoc); G2 (same list_halachot path); §6 (flagged items routed to a visible bucket, not dropped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +20,9 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
|
||||
thin_restatement: "ניסוח דק",
|
||||
quote_unverified: "ציטוט לא מאומת",
|
||||
nli_unsupported: "כלל לא נגזר מהציטוט",
|
||||
application: "יישום תלוי-עובדות",
|
||||
near_duplicate: "כפילות-קרובה",
|
||||
nevo_preamble_leak: "דליפת רציו נבו",
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null | undefined) {
|
||||
@@ -57,13 +60,15 @@ type EditState = { rule_statement: string; reasoning_summary: string };
|
||||
function HalachaCard({
|
||||
h, focused, onApprove, onReject, onDefer, onSave,
|
||||
}: {
|
||||
h: Halacha;
|
||||
h: Halacha & { variants?: Halacha[] };
|
||||
focused: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDefer: () => void;
|
||||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||||
}) {
|
||||
const variants = h.variants ?? [];
|
||||
const [showVariants, setShowVariants] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<EditState>({
|
||||
rule_statement: h.rule_statement,
|
||||
@@ -111,6 +116,12 @@ function HalachaCard({
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
{ruleTypeLabel(h.rule_type)}
|
||||
</Badge>
|
||||
{variants.length > 0 && (
|
||||
<Badge variant="outline"
|
||||
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
|
||||
+{variants.length} וריאנטים
|
||||
</Badge>
|
||||
)}
|
||||
<CorroborationBadge halacha={h} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -180,6 +191,35 @@ function HalachaCard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="rounded-md border border-navy/20 bg-navy-soft/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVariants((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-navy hover:bg-navy-soft/20 transition-colors"
|
||||
aria-expanded={showVariants}
|
||||
>
|
||||
{showVariants ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
<span className="font-medium">
|
||||
{variants.length} ניסוחים כמעט-זהים של אותו עיקרון
|
||||
</span>
|
||||
<span className="me-auto text-ink-muted">החלטה תחול על כולם</span>
|
||||
</button>
|
||||
{showVariants && (
|
||||
<ul className="px-4 pb-3 pt-1 space-y-2">
|
||||
{variants.map((v) => (
|
||||
<li key={v.id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-navy/20 pr-3" dir="rtl">
|
||||
{v.rule_statement}
|
||||
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
|
||||
(ביטחון {v.confidence.toFixed(2)})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
||||
{editing ? (
|
||||
<>
|
||||
@@ -292,37 +332,64 @@ function HalachaRestoreCard({
|
||||
|
||||
// ─── Shared group type ────────────────────────────────────────────────────────
|
||||
|
||||
/** #84.2 — a review card: the canonical halacha plus its near-duplicate
|
||||
* variants (empty for singletons). Acting on the card acts on the whole set. */
|
||||
type ReviewItem = Halacha & { variants: Halacha[] };
|
||||
|
||||
type Group = {
|
||||
caseLawId: string;
|
||||
caseNumber: string;
|
||||
court: string;
|
||||
decisionDate: string | null;
|
||||
precedentLevel: string;
|
||||
items: Halacha[];
|
||||
items: ReviewItem[]; // canonicals (clusters collapsed)
|
||||
pendingTotal: number; // total halachot incl. variants (for the count badge)
|
||||
};
|
||||
|
||||
/** All halacha ids represented by a review item (canonical + its variants). */
|
||||
function itemIds(it: ReviewItem): string[] {
|
||||
return [it.id, ...it.variants.map((v) => v.id)];
|
||||
}
|
||||
|
||||
function buildGroups(items: Halacha[]): Group[] {
|
||||
const map = new Map<string, Group>();
|
||||
const byCase = new Map<string, Halacha[]>();
|
||||
for (const h of items) {
|
||||
const k = h.case_law_id;
|
||||
let g = map.get(k);
|
||||
if (!g) {
|
||||
g = {
|
||||
caseLawId: k,
|
||||
caseNumber: h.case_number ?? "",
|
||||
court: h.court ?? "",
|
||||
decisionDate: h.decision_date ?? null,
|
||||
precedentLevel: h.precedent_level ?? "",
|
||||
items: [],
|
||||
};
|
||||
map.set(k, g);
|
||||
const arr = byCase.get(h.case_law_id) ?? [];
|
||||
arr.push(h);
|
||||
byCase.set(h.case_law_id, arr);
|
||||
}
|
||||
const groups: Group[] = [];
|
||||
for (const [caseLawId, members] of byCase) {
|
||||
// collapse near-duplicate clusters (#84.2): one card per cluster_id.
|
||||
const clusters = new Map<string, Halacha[]>();
|
||||
for (const h of members) {
|
||||
const key = h.cluster_id ?? h.id;
|
||||
const arr = clusters.get(key) ?? [];
|
||||
arr.push(h);
|
||||
clusters.set(key, arr);
|
||||
}
|
||||
g.items.push(h);
|
||||
const reviewItems: ReviewItem[] = [];
|
||||
for (const mem of clusters.values()) {
|
||||
// canonical = strongest phrasing (highest confidence, verified wins ties)
|
||||
mem.sort((a, b) =>
|
||||
b.confidence - a.confidence
|
||||
|| Number(b.quote_verified) - Number(a.quote_verified));
|
||||
reviewItems.push({ ...mem[0], variants: mem.slice(1) });
|
||||
}
|
||||
// active-learning order within the case: most-uncertain first
|
||||
reviewItems.sort((a, b) => a.confidence - b.confidence);
|
||||
const first = members[0];
|
||||
groups.push({
|
||||
caseLawId,
|
||||
caseNumber: first.case_number ?? "",
|
||||
court: first.court ?? "",
|
||||
decisionDate: first.decision_date ?? null,
|
||||
precedentLevel: first.precedent_level ?? "",
|
||||
items: reviewItems,
|
||||
pendingTotal: members.length,
|
||||
});
|
||||
}
|
||||
for (const g of map.values()) {
|
||||
g.items.sort((a, b) => a.confidence - b.confidence);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length);
|
||||
return groups.sort((a, b) => b.pendingTotal - a.pendingTotal);
|
||||
}
|
||||
|
||||
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
|
||||
@@ -422,7 +489,7 @@ function RestorePanel({
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{g.items.length}
|
||||
{g.pendingTotal}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
@@ -450,7 +517,12 @@ function RestorePanel({
|
||||
// ─── Pending queue panel (main review flow) ───────────────────────────────────
|
||||
|
||||
function PendingPanel() {
|
||||
const { data, isPending, error } = useHalachotPending(500);
|
||||
// #84.1 — "clean" = quality-gated + prioritized + clustered review queue;
|
||||
// "needsfix" = the flagged 'needs extraction fix' bucket.
|
||||
const [view, setView] = useState<"clean" | "needsfix">("clean");
|
||||
const { data, isPending, error } = useHalachotPending({
|
||||
limit: 500, needsFix: view === "needsfix",
|
||||
});
|
||||
const update = useUpdateHalacha();
|
||||
const batch = useBatchReviewHalachot();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
@@ -463,8 +535,8 @@ function PendingPanel() {
|
||||
|
||||
const totalCount = data?.items.length ?? 0;
|
||||
|
||||
const visibleItems = useMemo<Halacha[]>(() => {
|
||||
const out: Halacha[] = [];
|
||||
const visibleItems = useMemo<ReviewItem[]>(() => {
|
||||
const out: ReviewItem[] = [];
|
||||
for (const g of groups) {
|
||||
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
||||
}
|
||||
@@ -483,7 +555,7 @@ function PendingPanel() {
|
||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [focusedId]);
|
||||
|
||||
const focused = focusedId
|
||||
const focused: ReviewItem | null = focusedId
|
||||
? visibleItems.find((h) => h.id === focusedId) ?? null
|
||||
: null;
|
||||
|
||||
@@ -503,16 +575,26 @@ function PendingPanel() {
|
||||
};
|
||||
|
||||
const review = async (
|
||||
h: Halacha,
|
||||
it: ReviewItem,
|
||||
status: "approved" | "rejected" | "deferred",
|
||||
extra?: Partial<EditState>,
|
||||
) => {
|
||||
const ids = itemIds(it);
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: h.id,
|
||||
patch: { review_status: status, ...extra },
|
||||
});
|
||||
toast.success(REVIEW_TOAST[status] ?? "עודכן");
|
||||
// #84.2 — a cluster card applies the decision to all its variants at once
|
||||
// (an edit, which is canonical-specific, stays single).
|
||||
if (ids.length > 1 && !extra) {
|
||||
await batch.mutateAsync({ ids, status });
|
||||
} else {
|
||||
await update.mutateAsync({
|
||||
id: it.id,
|
||||
patch: { review_status: status, ...extra },
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
(REVIEW_TOAST[status] ?? "עודכן")
|
||||
+ (ids.length > 1 ? ` (${ids.length} וריאנטים)` : ""),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||
}
|
||||
@@ -521,7 +603,7 @@ function PendingPanel() {
|
||||
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
||||
try {
|
||||
const res = await batch.mutateAsync({
|
||||
ids: g.items.map((h) => h.id), status,
|
||||
ids: g.items.flatMap(itemIds), status,
|
||||
});
|
||||
toast.success(
|
||||
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
||||
@@ -573,37 +655,63 @@ function PendingPanel() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focused, visibleItems]);
|
||||
|
||||
const viewToggle = (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "clean" ? "default" : "ghost"}
|
||||
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||
onClick={() => setView("clean")}
|
||||
>
|
||||
תור נקי
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "needsfix" ? "default" : "ghost"}
|
||||
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||
onClick={() => setView("needsfix")}
|
||||
>
|
||||
דורש תיקון-חילוץ
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
if (error) {
|
||||
return (
|
||||
body = (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||
{error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
} else if (isPending) {
|
||||
body = (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!groups.length) {
|
||||
return (
|
||||
} else if (!groups.length) {
|
||||
body = (
|
||||
<div className="text-center text-ink-muted py-16">
|
||||
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
|
||||
<p className="text-sm mt-2">העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן.</p>
|
||||
<p className="text-lg">
|
||||
{view === "needsfix"
|
||||
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
|
||||
: "אין הלכות נקיות הממתינות לאישור."}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
{view === "needsfix"
|
||||
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
|
||||
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
} else {
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
|
||||
<span>
|
||||
<span className="text-navy font-semibold">{totalCount}</span> ממתינות
|
||||
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
||||
<span className="text-navy font-semibold">{totalCount}</span>
|
||||
{view === "needsfix" ? " מסומנות" : " ממתינות"}
|
||||
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
||||
</span>
|
||||
<span className="me-auto 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>
|
||||
@@ -650,7 +758,8 @@ function PendingPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
|
||||
{g.items.length} ממתינות
|
||||
{g.pendingTotal} ממתינות
|
||||
{g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
@@ -658,12 +767,12 @@ function PendingPanel() {
|
||||
<div className="p-4 space-y-3 bg-rule-soft/20">
|
||||
<div className="flex items-center gap-2 justify-end pb-1">
|
||||
<span className="me-auto text-[0.72rem] text-ink-muted">
|
||||
פעולה קבוצתית על {g.items.length} ההלכות:
|
||||
פעולה קבוצתית על {g.pendingTotal} ההלכות:
|
||||
</span>
|
||||
<Button
|
||||
size="sm" variant="ghost" disabled={batch.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
|
||||
if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||
reviewGroup(g, "rejected");
|
||||
}}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||
@@ -673,7 +782,7 @@ function PendingPanel() {
|
||||
<Button
|
||||
size="sm" disabled={batch.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
|
||||
if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||
reviewGroup(g, "approved");
|
||||
}}
|
||||
className="bg-gold text-navy hover:bg-gold-deep"
|
||||
@@ -708,6 +817,14 @@ function PendingPanel() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{viewToggle}
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -730,7 +847,7 @@ const TAB_LABELS: Record<Tab, string> = {
|
||||
|
||||
export function HalachaReviewPanel() {
|
||||
const [tab, setTab] = useState<Tab>("pending");
|
||||
const { data: pendingData } = useHalachotPending(500);
|
||||
const { data: pendingData } = useHalachotPending({ limit: 500 });
|
||||
const rejectedCount = useHalachaCount("rejected");
|
||||
const approvedCount = useHalachaCount("approved");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user