From c83d0162ca1991106fe0ab9730acae4001a9a5c6 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 12:07:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(halacha):=20=D7=98=D7=90=D7=91=D7=99=D7=9D?= =?UTF-8?q?=20=D7=A0=D7=93=D7=97=D7=95/=D7=90=D7=95=D7=A9=D7=A8=D7=95=20+?= =?UTF-8?q?=20=D7=A9=D7=97=D7=96=D7=95=D7=A8=20=D7=94=D7=9C=D7=9B=D7=94=20?= =?UTF-8?q?+=20=D7=94=D7=A1=D7=A8=D7=AA=20placeholders=20=D7=A2=D7=9D=20?= =?UTF-8?q?=D7=A9=D7=9E=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - מוסיף טאב "נדחו" לדף האישורים: הלכות שנדחו מופיעות עם כפתורי "אשר" (ישירות) ו-"שחזר לתור" - מוסיף טאב "אושרו": הלכות שאושרו עם "בטל אישור" ו-"דחה" - ספירה צבועה על כל טאב (זהב/אדום/כחול) - מוסיף useHalachotByStatus hook ב-API - מסיר placeholders עם שמות ("דפנה תמיר") משדות יו"ר Co-Authored-By: Claude Sonnet 4.6 --- .../missing-precedent-detail-drawer.tsx | 2 +- .../precedents/halacha-review-panel.tsx | 400 ++++++++++++++---- .../precedents/precedent-edit-sheet.tsx | 2 +- web-ui/src/lib/api/precedent-library.ts | 13 + 4 files changed, 341 insertions(+), 76 deletions(-) diff --git a/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx index 9223119..8de8b5e 100644 --- a/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx +++ b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx @@ -324,7 +324,7 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) { id="chair_name" value={chairName} onChange={(e) => setChairName(e.target.value)} - placeholder="דפנה תמיר" + placeholder="" dir="rtl" /> diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index 8ecaa9e..302b27b 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock } from "lucide-react"; +import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea"; import { CorroborationBadge } from "./corroboration-badge"; import { practiceAreaLabel } from "./practice-area"; import { - useHalachotPending, useUpdateHalacha, useBatchReviewHalachot, type Halacha, + useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha, } from "@/lib/api/precedent-library"; /** #81 strict-rubric flags — why an item was held back from auto-approval. */ @@ -19,21 +19,9 @@ const QUALITY_FLAG_LABELS: Record = { truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק", quote_unverified: "ציטוט לא מאומת", + nli_unsupported: "כלל לא נגזר מהציטוט", }; -/** - * 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 { @@ -43,9 +31,7 @@ function formatDate(iso: string | null | undefined) { } } -/* 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. */ +/* Strip Unicode bidi marks that render as zero-width but shift visual position. */ function cleanCitation(s: string | null | undefined): string { if (!s) return "—"; return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); @@ -66,6 +52,8 @@ function ruleTypeLabel(t: string): string { type EditState = { rule_statement: string; reasoning_summary: string }; +// ─── Pending-queue card (full interactions) ─────────────────────────────────── + function HalachaCard({ h, focused, onApprove, onReject, onDefer, onSave, }: { @@ -82,7 +70,6 @@ function HalachaCard({ 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({ @@ -104,7 +91,6 @@ function HalachaCard({ ${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"} `} > - {/* Header — status pills only (citation is in the group header) */}
{h.page_reference && ( {h.page_reference} @@ -129,7 +115,6 @@ function HalachaCard({
- {/* #81 quality flags — explain why this item needs a human eye */} {h.quality_flags && h.quality_flags.length > 0 && (
{h.quality_flags.map((f) => ( @@ -142,7 +127,6 @@ function HalachaCard({
)} - {/* Side-by-side rule vs quote */}
ניסוח הכלל
@@ -166,7 +150,6 @@ function HalachaCard({
- {/* Reasoning */} {(editing || h.reasoning_summary) && (
תמצית ההיגיון
@@ -184,7 +167,6 @@ function HalachaCard({
)} - {/* Tags */}
{h.practice_areas?.map((p) => ( @@ -198,7 +180,6 @@ function HalachaCard({ ))}
- {/* Actions */}
{editing ? ( <> @@ -239,6 +220,78 @@ function HalachaCard({ ); } +// ─── Restore card (rejected / approved tabs) ────────────────────────────────── + +function HalachaRestoreCard({ + h, + primaryLabel, + primaryAction, + secondaryLabel, + secondaryAction, +}: { + h: Halacha; + primaryLabel: string; + primaryAction: () => void; + secondaryLabel: string; + secondaryAction: () => void; +}) { + return ( +
+
+ {h.page_reference && {h.page_reference}} + + + ביטחון {h.confidence.toFixed(2)} + + + {ruleTypeLabel(h.rule_type)} + + + +
+ +
+
+
ניסוח הכלל
+

+ {h.rule_statement} +

+
+
+
ציטוט תומך
+
+ “{h.supporting_quote}” +
+
+
+ +
+ {h.practice_areas?.map((p) => ( + + {practiceAreaLabel(p)} + + ))} +
+ +
+ + +
+
+ ); +} + +// ─── Shared group type ──────────────────────────────────────────────────────── + type Group = { caseLawId: string; caseNumber: string; @@ -248,44 +301,168 @@ type Group = { items: Halacha[]; }; -export function HalachaReviewPanel() { +function buildGroups(items: Halacha[]): Group[] { + const map = new Map(); + 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); + } + 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); +} + +// ─── Restore panel (used for "rejected" and "approved" tabs) ────────────────── + +function RestorePanel({ + status, + primaryLabel, + getPrimaryStatus, + secondaryLabel, + getSecondaryStatus, +}: { + status: string; + primaryLabel: string; + getPrimaryStatus: () => "approved" | "rejected" | "pending_review"; + secondaryLabel: string; + getSecondaryStatus: () => "approved" | "rejected" | "pending_review"; +}) { + const { data, isPending, error } = useHalachotByStatus(status); + const update = useUpdateHalacha(); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const groups = useMemo( + () => buildGroups(data?.items ?? []), + [data], + ); + + const restore = async (h: Halacha, newStatus: "approved" | "rejected" | "pending_review") => { + try { + await update.mutateAsync({ id: h.id, patch: { review_status: newStatus } }); + toast.success("עודכן"); + } 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); + return next; + }); + }; + + if (error) { + return ( +
+ {error.message} +
+ ); + } + + if (isPending) { + return ( +
+ {[...Array(3)].map((_, i) => )} +
+ ); + } + + if (!groups.length) { + return ( +
+

אין הלכות בסטטוס זה.

+
+ ); + } + + return ( +
+ {groups.map((g) => { + const isOpen = expandedIds.has(g.caseLawId); + return ( +
+ + + {isOpen && ( +
+ {g.items.map((h) => ( + restore(h, getPrimaryStatus())} + secondaryLabel={secondaryLabel} + secondaryAction={() => restore(h, getSecondaryStatus())} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} + +// ─── Pending queue panel (main review flow) ─────────────────────────────────── + +function PendingPanel() { const { data, isPending, error } = useHalachotPending(500); const update = useUpdateHalacha(); const batch = useBatchReviewHalachot(); const [expandedIds, setExpandedIds] = useState>(new Set()); const [focusedId, setFocusedId] = useState(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(() => { - const map = new Map(); - 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 groups = useMemo( + () => buildGroups(data?.items ?? []), + [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(() => { const out: Halacha[] = []; for (const g of groups) { @@ -294,8 +471,6 @@ export function HalachaReviewPanel() { 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; @@ -303,7 +478,6 @@ export function HalachaReviewPanel() { 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}"]`); @@ -345,7 +519,6 @@ export function HalachaReviewPanel() { } }; - // #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({ @@ -366,7 +539,6 @@ export function HalachaReviewPanel() { 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); @@ -376,8 +548,6 @@ export function HalachaReviewPanel() { }); }; - // 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(); @@ -444,10 +614,7 @@ export function HalachaReviewPanel() { {" "}· עריכה: E {expandedIds.size > 0 && ( - )} @@ -457,10 +624,7 @@ export function HalachaReviewPanel() { {groups.map((g) => { const isOpen = expandedIds.has(g.caseLawId); return ( -
+
- + {g.items.length} ממתינות {isOpen && (
- {/* #84 — group batch actions: one decision for the whole ruling */}
פעולה קבוצתית על {g.items.length} ההלכות: @@ -551,3 +711,95 @@ export function HalachaReviewPanel() {
); } + +// ─── Count badge for tabs ───────────────────────────────────────────────────── + +function useHalachaCount(status: string) { + const { data } = useHalachotByStatus(status, 1000); + return data?.count ?? data?.items.length ?? null; +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +type Tab = "pending" | "rejected" | "approved"; + +const TAB_LABELS: Record = { + pending: "ממתינות", + rejected: "נדחו", + approved: "אושרו", +}; + +export function HalachaReviewPanel() { + const [tab, setTab] = useState("pending"); + const { data: pendingData } = useHalachotPending(500); + const rejectedCount = useHalachaCount("rejected"); + const approvedCount = useHalachaCount("approved"); + + const pendingCount = pendingData?.items.length ?? null; + + const counts: Record = { + pending: pendingCount, + rejected: rejectedCount, + approved: approvedCount, + }; + + return ( +
+ {/* Tab bar */} +
+ {(["pending", "rejected", "approved"] as Tab[]).map((t) => ( + + ))} +
+ + {/* Tab content */} + {tab === "pending" && } + + {tab === "rejected" && ( + "approved"} + secondaryLabel="שחזר לתור" + getSecondaryStatus={() => "pending_review"} + /> + )} + + {tab === "approved" && ( + "pending_review"} + secondaryLabel="דחה" + getSecondaryStatus={() => "rejected"} + /> + )} +
+ ); +} diff --git a/web-ui/src/components/precedents/precedent-edit-sheet.tsx b/web-ui/src/components/precedents/precedent-edit-sheet.tsx index 7391713..440e1e5 100644 --- a/web-ui/src/components/precedents/precedent-edit-sheet.tsx +++ b/web-ui/src/components/precedents/precedent-edit-sheet.tsx @@ -242,7 +242,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) { setForm({ ...form, chair_name: e.target.value })} - placeholder="עו״ד דפנה תמיר" /> + placeholder="" />
diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 179fcb9..fcf3376 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -576,6 +576,19 @@ export function useHalachotPending(limit = 200) { }); } +export function useHalachotByStatus(status: string, limit = 300) { + return useQuery({ + queryKey: libraryKeys.halachot({ review_status: status, limit: String(limit) }), + queryFn: ({ signal }) => + apiRequest<{ items: Halacha[]; count: number }>( + `/api/halachot?review_status=${encodeURIComponent(status)}&limit=${limit}`, + { signal }, + ), + staleTime: 10_000, + refetchOnMount: "always", + }); +} + export type HalachaPatch = Partial<{ review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred"; reviewer: string;