"use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info } 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, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, isExtractionFixItem, type Halacha, } from "@/lib/api/precedent-library"; import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta"; /** #81 strict-rubric flags — why an item was held back from auto-approval. */ const QUALITY_FLAG_LABELS: Record = { non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק", quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר מהציטוט", application: "יישום תלוי-עובדות", near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו נבו", }; function formatDate(iso: string | null | undefined) { if (!iso) return "—"; try { return new Date(iso).toLocaleDateString("he-IL"); } catch { return iso; } } /* 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(); } type EditState = { rule_statement: string; reasoning_summary: string; supporting_quote: string; // #133 — editing this re-verifies vs the source }; // ─── Panel deliberation (#133/FU-2) ─────────────────────────────────────────── // Surfaces the 3-judge panel's vote+rationale inside the chair's review card so // her decision — the gold label the active-learning loop learns from — is informed // by WHY the panel split. Display-only; never affects review_status (INV-G10). const JUDGE_META: Record = { claude: { name: "Claude", lineage: "Anthropic" }, deepseek: { name: "DeepSeek", lineage: "DeepSeek" }, gemini: { name: "Gemini", lineage: "Google" }, }; const VERDICT_META: Record = { unanimous_yes: { label: "פה-אחד", cls: "bg-success text-white" }, unanimous_no: { label: "פה-אחד נגד", cls: "bg-danger text-white" }, split: { label: "פיצול", cls: "bg-warn text-white" }, incomplete: { label: "חלקי", cls: "bg-ink-muted text-white" }, }; function PanelDeliberation({ round }: { round: NonNullable }) { const isEntail = round.question === "entailed"; const yesLabel = isEntail ? "נתמך" : "לשמירה"; const noLabel = isEntail ? "הכלל חורג" : "לפסילה"; const question = isEntail ? "האם הציטוט תומך בכלל ואינו מרחיב מעבר לו?" : "האם זו הלכה בת-הכללה לשמירה?"; const votes = round.judges.map((j) => j.vote).filter((v): v is boolean => v !== null); const yes = votes.filter(Boolean).length; const no = votes.length - yes; const v = VERDICT_META[round.verdict] ?? VERDICT_META.incomplete; const ratio = round.verdict === "split" ? ` ${Math.max(yes, no)}:${Math.min(yes, no)}` : ""; // The strict-vs-gist explainer only makes sense on a split over entailment. const showHint = round.verdict === "split" && isEntail; return (
התלבטות הפאנל {v.label}{ratio} השאלה: ״{question}״
{round.judges.map((j) => { const yesVote = j.vote === true; const noVote = j.vote === false; const meta = JUDGE_META[j.model] ?? { name: j.model, lineage: "" }; return (
{meta.name} {meta.lineage}
{j.vote === null ? "— ללא" : yesVote ? `✓ ${yesLabel}` : `✗ ${noLabel}`}

{j.reason || "—"}

); })} {showHint && (

שורש המחלוקת:{" "} סטנדרט קפדני (כל פרט בכלל חייב להופיע בציטוט) מול סטנדרט-תמצית (הגרעין המשפטי נתמך). הכרעתך קובעת איזה סטנדרט הפאנל יאמץ.

)}
); } // ─── Pending-queue card (full interactions) ─────────────────────────────────── function HalachaCard({ h, focused, onApprove, onReject, onDefer, onSave, }: { h: Halacha & { variants?: Halacha[] }; focused: boolean; onApprove: () => void; onReject: () => void; onDefer: () => void; onSave: (patch: Partial) => Promise; }) { const variants = h.variants ?? []; const equivalents = h.equivalents ?? []; const [showVariants, setShowVariants] = useState(false); const [showEquiv, setShowEquiv] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState({ rule_statement: h.rule_statement, reasoning_summary: h.reasoning_summary, supporting_quote: h.supporting_quote, }); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setDraft({ rule_statement: h.rule_statement, reasoning_summary: h.reasoning_summary, supporting_quote: h.supporting_quote, }); }, [h.id, h.rule_statement, h.reasoning_summary, h.supporting_quote]); const onSubmitEdit = async () => { // Only send the quote when it actually changed — that triggers server-side // re-verification + flag sync (#133); unchanged edits keep the old behavior. const patch: Partial = { rule_statement: draft.rule_statement, reasoning_summary: draft.reasoning_summary, }; if (draft.supporting_quote !== h.supporting_quote) { patch.supporting_quote = draft.supporting_quote; } await onSave(patch); setEditing(false); }; return (
{h.page_reference && ( {h.page_reference} )} {h.quote_verified ? ( ציטוט מאומת ) : ( ציטוט לא מאומת )} ביטחון {h.confidence.toFixed(2)} {ruleTypeLabel(h.rule_type)} {variants.length > 0 && ( +{variants.length} וריאנטים )} {equivalents.length > 0 && ( עיקרון מקביל ב-{equivalents.length} )}
{h.quality_flags && h.quality_flags.length > 0 && (
{h.quality_flags.map((f) => ( {QUALITY_FLAG_LABELS[f] ?? f} ))}
)}
ניסוח הכלל
{editing ? (