Files
legal-ai/web-ui/src/components/precedents/halacha-review-panel.tsx
Chaim eeb70a5758 feat(halacha): review-queue triage — defer + batch group actions + quality-flag badges (#84)
Make the chair's pending-halacha review faster and less exhausting.

Backend:
- New 'deferred' review_status (snooze): stays out of the active library AND
  out of the default pending queue, without the finality of 'rejected'.
  update_halacha stamps reviewer+reviewed_at on defer; HALACHA_REVIEW_STATUSES
  is the single source of valid statuses (PATCH validation now uses it).
- db.update_halachot_batch(ids, status, reviewer) — one atomic UPDATE for a
  whole group; invalid status / empty ids are a no-op.
- POST /api/halachot/batch (HalachaBatchReviewRequest) wraps it.
- update_halacha now RETURNs quality_flags too (parity with list_halachot).

Frontend (halacha-review-panel):
- Quality-flag badges (#81: non_decision / truncated_quote / thin_restatement /
  quote_unverified) so the chair sees WHY an item was held back.
- Defer action — button + keyboard 'D' — to snooze without rejecting (fixes the
  'leave in pending forever' anti-pattern; reject stays the junk verb).
- Per-precedent batch bar: 'אשר הכל' / 'דחה הכל' via useBatchReviewHalachot
  (one request, one refetch) with confirm guards.
- Halacha/HalachaPatch types gain quality_flags + 'deferred'.

Verified: mcp-server suite 156 passed; web build green; end-to-end integration
against dev DB (batch approve/reject, defer sets status+timestamp, pending
excludes approved+deferred, deferred queryable, invalid status no-op).

Note: api:types regen deferred until deploy (the batch hook is hand-typed, not
dependent on generated types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:42:21 +00:00

554 lines
20 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, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock } 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 { Textarea } from "@/components/ui/textarea";
import { CorroborationBadge } from "./corroboration-badge";
import { practiceAreaLabel } from "./practice-area";
import {
useHalachotPending, useUpdateHalacha, useBatchReviewHalachot, type Halacha,
} from "@/lib/api/precedent-library";
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
const QUALITY_FLAG_LABELS: Record<string, string> = {
non_decision: "אי-הכרעה",
truncated_quote: "ציטוט קטוע",
thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת",
};
/**
* Halacha review queue — the chair-only path between automatic
* extraction and agent visibility. Per the project's review policy,
* NO halacha is auto-published; every row sits in pending_review until
* approved.
*
* UX: items are grouped by precedent (case_law_id). Groups start
* collapsed so the chair picks one ruling at a time. Within an open
* group, J/K navigates, A approves, R rejects, E edits. Items inside
* each group are sorted by confidence ascending so the doubtful ones
* surface first.
*/
function formatDate(iso: string | null | undefined) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/
* isolate) inside the citation. They render as zero-width but visually push
* the text away from where it should sit. Strip for display only. */
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
const RULE_TYPE_LABELS: Record<string, string> = {
binding: "הלכה מחייבת",
interpretive: "פרשני",
procedural: "פרוצדורלי",
obiter: "אמרת אגב",
application: "יישום הלכה",
persuasive: "משכנע",
};
function ruleTypeLabel(t: string): string {
return RULE_TYPE_LABELS[t] ?? t;
}
type EditState = { rule_statement: string; reasoning_summary: string };
function HalachaCard({
h, focused, onApprove, onReject, onDefer, onSave,
}: {
h: Halacha;
focused: boolean;
onApprove: () => void;
onReject: () => void;
onDefer: () => void;
onSave: (patch: Partial<EditState>) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
});
// Reset draft when underlying row changes (focus moves to a new card).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
});
}, [h.id, h.rule_statement, h.reasoning_summary]);
const onSubmitEdit = async () => {
await onSave(draft);
setEditing(false);
};
return (
<div
data-halacha-id={h.id}
className={`
rounded-lg border bg-surface p-4 space-y-3 transition-colors
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"}
`}
>
{/* Header — status pills only (citation is in the group header) */}
<div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap">
{h.page_reference && (
<span className="text-[0.7rem]">{h.page_reference}</span>
)}
<span className="ms-auto flex items-center gap-2">
{h.quote_verified ? (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
<Check className="w-3 h-3 me-1" /> ציטוט מאומת
</Badge>
) : (
<Badge variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
<AlertTriangle className="w-3 h-3 me-1" /> ציטוט לא מאומת
</Badge>
)}
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<CorroborationBadge halacha={h} />
</span>
</div>
{/* #81 quality flags — explain why this item needs a human eye */}
{h.quality_flags && h.quality_flags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{h.quality_flags.map((f) => (
<Badge key={f} variant="outline"
className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
<AlertTriangle className="w-3 h-3 me-1" />
{QUALITY_FLAG_LABELS[f] ?? f}
</Badge>
))}
</div>
)}
{/* Side-by-side rule vs quote */}
<div className="grid md:grid-cols-2 gap-3">
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ניסוח הכלל</div>
{editing ? (
<Textarea
value={draft.rule_statement} rows={4} dir="rtl"
onChange={(e) => setDraft({ ...draft, rule_statement: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-navy font-medium leading-relaxed" dir="rtl">
{h.rule_statement}
</p>
)}
</div>
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo;
</blockquote>
</div>
</div>
{/* Reasoning */}
{(editing || h.reasoning_summary) && (
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
{editing ? (
<Textarea
value={draft.reasoning_summary} rows={2} dir="rtl"
onChange={(e) => setDraft({ ...draft, reasoning_summary: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-ink-soft text-sm leading-relaxed" dir="rtl">
{h.reasoning_summary}
</p>
)}
</div>
)}
{/* Tags */}
<div className="flex items-center gap-2 flex-wrap">
{h.practice_areas?.map((p) => (
<Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy">
{practiceAreaLabel(p)}
</Badge>
))}
{h.subject_tags?.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
ביטול
</Button>
<Button size="sm" onClick={onSubmitEdit}
className="bg-navy text-parchment hover:bg-navy-soft">
שמור שינויים
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Edit2 className="w-3.5 h-3.5 me-1" />
ערוך (E)
</Button>
<Button size="sm" variant="ghost" onClick={onDefer}
className="text-ink-muted hover:text-navy">
<Clock className="w-3.5 h-3.5 me-1" />
דחה למועד (D)
</Button>
<Button size="sm" variant="ghost"
onClick={onReject}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3.5 h-3.5 me-1" />
דחה (R)
</Button>
<Button size="sm" onClick={onApprove}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
אשר (A)
</Button>
</>
)}
</div>
</div>
);
}
type Group = {
caseLawId: string;
caseNumber: string;
court: string;
decisionDate: string | null;
precedentLevel: string;
items: Halacha[];
};
export function HalachaReviewPanel() {
const { data, isPending, error } = useHalachotPending(500);
const update = useUpdateHalacha();
const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null);
// Group by precedent. Within each group, items sorted by confidence ascending
// so the chair sees doubtful entries first. Groups themselves sort by item
// count descending — biggest piles surface first.
const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>();
for (const h of data?.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);
}
g.items.push(h);
}
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);
}, [data]);
const totalCount = data?.items.length ?? 0;
// Items the keyboard handler can navigate. Only items inside expanded
// groups are "visible" — collapsed groups hide their items from J/K.
const visibleItems = useMemo<Halacha[]>(() => {
const out: Halacha[] = [];
for (const g of groups) {
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
}
return out;
}, [groups, expandedIds]);
// If the focused item disappears (approved/rejected/group-collapsed), pick
// a sensible neighbour so the highlight doesn't vanish silently.
useEffect(() => {
if (focusedId === null) return;
if (visibleItems.some((h) => h.id === focusedId)) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFocusedId(visibleItems[0]?.id ?? null);
}, [focusedId, visibleItems]);
// Scroll the focused row into view
useEffect(() => {
if (!focusedId) return;
const el = document.querySelector(`[data-halacha-id="${focusedId}"]`);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]);
const focused = focusedId
? visibleItems.find((h) => h.id === focusedId) ?? null
: null;
const moveFocus = (delta: 1 | -1) => {
if (visibleItems.length === 0) return;
const idx = focusedId
? visibleItems.findIndex((h) => h.id === focusedId)
: -1;
const next = idx < 0
? (delta > 0 ? 0 : visibleItems.length - 1)
: Math.max(0, Math.min(visibleItems.length - 1, idx + delta));
setFocusedId(visibleItems[next].id);
};
const REVIEW_TOAST: Record<string, string> = {
approved: "אושר", rejected: "נדחה", deferred: "נדחה למועד",
};
const review = async (
h: Halacha,
status: "approved" | "rejected" | "deferred",
extra?: Partial<EditState>,
) => {
try {
await update.mutateAsync({
id: h.id,
patch: { review_status: status, ...extra },
});
toast.success(REVIEW_TOAST[status] ?? "עודכן");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
// #84 — one decision applied to a whole precedent group (one request).
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
try {
const res = await batch.mutateAsync({
ids: g.items.map((h) => h.id), status,
});
toast.success(
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const toggleGroup = (caseLawId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(caseLawId)) {
next.delete(caseLawId);
} else {
next.add(caseLawId);
// Auto-focus first item of the just-opened group
const g = groups.find((x) => x.caseLawId === caseLawId);
if (g && g.items.length) {
setTimeout(() => setFocusedId(g.items[0].id), 0);
}
}
return next;
});
};
// Keyboard navigation — only acts when something is focused (i.e. at
// least one group is open).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return;
if (e.key === "j") {
e.preventDefault();
moveFocus(1);
} else if (e.key === "k") {
e.preventDefault();
moveFocus(-1);
} else if ((e.key === "a" || e.key === "A") && focused) {
e.preventDefault();
review(focused, "approved");
} else if ((e.key === "r" || e.key === "R") && focused) {
e.preventDefault();
if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
} else if ((e.key === "d" || e.key === "D") && focused) {
e.preventDefault();
review(focused, "deferred");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visibleItems]);
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-24 w-full" />)}
</div>
);
}
if (!groups.length) {
return (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
<p className="text-sm mt-2">העלה פסיקה חדשה ההלכות שיחולצו ממנה יופיעו כאן.</p>
</div>
);
}
return (
<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>
<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>
{" "}· אישור: <kbd className="bg-rule-soft px-1.5 rounded">A</kbd>
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd>
{" "}· למועד: <kbd className="bg-rule-soft px-1.5 rounded">D</kbd>
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
</span>
{expandedIds.size > 0 && (
<Button
size="sm" variant="ghost"
onClick={() => setExpandedIds(new Set())}
>
סגור הכל
</Button>
)}
</div>
<div className="space-y-3">
{groups.map((g) => {
const isOpen = expandedIds.has(g.caseLawId);
return (
<div
key={g.caseLawId}
className="rounded-lg border border-rule bg-surface overflow-hidden"
>
<button
type="button"
onClick={() => toggleGroup(g.caseLawId)}
className={`
w-full flex items-center gap-3 px-4 py-3 text-right
hover:bg-gold-wash/30 transition-colors
${isOpen ? "bg-gold-wash/40 border-b border-rule" : ""}
`}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-ink-muted shrink-0" />
) : (
<ChevronLeft className="w-4 h-4 text-ink-muted shrink-0" />
)}
<div className="flex-1 min-w-0 text-right">
<div className="font-semibold text-navy truncate">
{cleanCitation(g.caseNumber)}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
{g.court && <span>{g.court}</span>}
{g.decisionDate && <span>· {formatDate(g.decisionDate)}</span>}
{g.precedentLevel && <span>· {g.precedentLevel}</span>}
</div>
</div>
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums"
>
{g.items.length} ממתינות
</Badge>
</button>
{isOpen && (
<div className="p-4 space-y-3 bg-rule-soft/20">
{/* #84 — group batch actions: one decision for the whole ruling */}
<div className="flex items-center gap-2 justify-end pb-1">
<span className="me-auto text-[0.72rem] text-ink-muted">
פעולה קבוצתית על {g.items.length} ההלכות:
</span>
<Button
size="sm" variant="ghost" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
reviewGroup(g, "rejected");
}}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<X className="w-3.5 h-3.5 me-1" /> דחה הכל
</Button>
<Button
size="sm" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
reviewGroup(g, "approved");
}}
className="bg-gold text-navy hover:bg-gold-deep"
>
<Check className="w-3.5 h-3.5 me-1" /> אשר הכל
</Button>
</div>
{g.items.map((h) => (
<HalachaCard
key={h.id}
h={h}
focused={h.id === focusedId}
onApprove={() => review(h, "approved")}
onReject={() => {
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
}}
onDefer={() => review(h, "deferred")}
onSave={async (patch) => {
try {
await update.mutateAsync({ id: h.id, patch });
toast.success("נשמר");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
}}
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}