Files
legal-ai/web-ui/src/components/precedents/precedent-edit-sheet.tsx
Chaim 406e93b9bf
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(precedents): חילוץ-מטא-דאטה ממלא תחום (practice_area) ושם-יו"ר לכל החלטת-ועדה
שני פערים שצפו מ-/precedents בחילוץ-ההלכות:

1. **practice_area לא סומן** — השדה הועבר ל-LLM כקונטקסט-קריאה-בלבד ולא חולץ
   מעולם, כך שהעלאות שהשאירו אותו ריק נשארו ריקות והרדיו ב-/precedents הופיע
   ללא בחירה. עכשיו נגזר ב-apply_to_record: עדיפות לגזירה דטרמיניסטית מקידומת
   מספר-התיק (1xxx→rishuy, 8xxx→היטל, 9xxx→197 — מקור-אמת לדוקטי ועדת-ערר,
   INV-AH rule-based), ובנפילה — סיווג-תוכן של ה-LLM (שדה practice_area חדש
   בפרומפט, אנום-סגור) עבור פסקי-בית-משפט שהקידומת שלהם אינה מקודדת תחום.
   ממלא רק כשריק (G1 — נרמול במקור, לא תיקון-בקריאה).

2. **שם-יו"ר לא חולץ** (למשל 1132-09-24) — המיזוג היה מגודר על
   source_kind=='internal_committee' בלבד, ודילג בשקט על החלטות-ועדה שהועלו
   במסלול הפסיקה החיצוני (external_upload + source_type=appeals_committee, כמו
   החלטת ת"א מנבו) — היו"ר ישב בבלוק-החתימה אך לא חולץ. עכשיו מגודר על "האם זו
   החלטת-ועדה" (source_type/level אפקטיביים), לעולם לא על פסק-בית-משפט. ה-CHECK
   כופה non-empty רק ל-internal_committee, לכן כתיבה ל-external בטוחה.

חיזוק-פרומפט (לבקשת היו"ר): chair_name מציין מפורשות את בלוק-החתימה הדו-טורי
(מזכיר↔יו"ר — לקחת את צד-היו"ר) ומזהיר לא לחלץ יו"ר של פסקי-דין **מצוטטים**
בגוף ההחלטה.

UI (לוגיקה-בלבד, פטור משער-העיצוב): edit-sheet מסנכרן-מחדש מהרשומה הטרייה בכל
פתיחה (re-arm על סגירה) ו-usePrecedent עושה poll בזמן חילוץ — כך מילוי-רקע של
practice_area/chair_name מופיע בלי refresh מלא ("הכפתור לא נשאר מסומן").

בדיקות: test_metadata_extract_chair_practice_area.py (6 תרחישי-מיזוג, offline).

Invariants: G1 (נרמול-במקור), G2 (אותו extractor, לא מסלול מקביל),
INV-AH (גזירה דטרמיניסטית מועדפת, abstention כשאין ודאות).

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

369 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useState } from "react";
import { Save, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
usePrecedent,
useUpdatePrecedent,
useRequestMetadataExtraction,
type PracticeArea,
type SourceType,
} from "@/lib/api/precedent-library";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
} from "./practice-area";
import { ExtractedHalachotSection } from "./extracted-halachot";
type Props = {
caseLawId: string | null;
onOpenChange: (open: boolean) => void;
};
/* All editable fields. Pulled fresh from /api/precedent-library/{id}
* each time the sheet opens so the form reflects any auto-fill that
* happened in the background. */
type FormState = {
case_number: string;
citation_formatted: string;
case_name: string;
court: string;
district: string;
chair_name: string;
decision_date: string;
practice_area: PracticeArea;
appeal_subtype: string;
source_type: SourceType;
precedent_level: string;
is_binding: boolean;
subject_tags: string;
summary: string;
headnote: string;
key_quote: string;
};
const EMPTY: FormState = {
case_number: "", citation_formatted: "",
case_name: "", court: "", district: "", chair_name: "",
decision_date: "", practice_area: "", appeal_subtype: "", source_type: "",
precedent_level: "", is_binding: true, subject_tags: "",
summary: "", headnote: "", key_quote: "",
};
export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
const open = caseLawId !== null;
const { data: record, isPending } = usePrecedent(caseLawId);
const update = useUpdatePrecedent();
const requestMetadata = useRequestMetadataExtraction();
const [form, setForm] = useState<FormState>(EMPTY);
// React-approved derived-state pattern: sync form whenever a different
// record arrives (including after save+refetch). Using setState during
// render avoids the one-frame flash that useEffect would produce.
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
// Re-arm the sync on close so the NEXT open re-pulls the latest server record.
// The component is always mounted, so without this the form syncs once per
// precedent-id for the component's lifetime — and shows stale fields (e.g. a
// practice_area / chair_name that background metadata extraction filled AFTER
// the last open) until a full page refresh. Resetting on close makes reopening
// the sheet reflect the freshest record (which usePrecedent re-fetches while a
// row is mid-extraction). Both guards flip to false, so this render-phase
// setState terminates.
if (!open && syncedRecordId !== null) {
setSyncedRecordId(null);
}
if (open && record && record.id !== syncedRecordId) {
setSyncedRecordId(record.id as string);
setForm({
case_number: record.case_number || "",
citation_formatted: record.citation_formatted || "",
case_name: record.case_name || "",
court: record.court || "",
district: record.district || "",
chair_name: record.chair_name || "",
decision_date: record.date ? record.date.slice(0, 10) : "",
practice_area: (record.practice_area || "") as PracticeArea,
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
source_type: (record.source_type || "") as SourceType,
precedent_level: record.precedent_level || "",
is_binding: record.is_binding ?? true,
subject_tags: (record.subject_tags || []).join(", "),
summary: record.summary || "",
headnote: record.headnote || "",
key_quote: (record as { key_quote?: string }).key_quote || "",
});
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!caseLawId) return;
try {
const patch: Record<string, unknown> = {
citation_formatted: form.citation_formatted.trim(),
case_name: form.case_name.trim(),
court: form.court.trim(),
district: form.district.trim(),
chair_name: form.chair_name.trim(),
practice_area: form.practice_area || undefined,
appeal_subtype: form.appeal_subtype.trim(),
source_type: form.source_type || undefined,
precedent_level: form.precedent_level || undefined,
is_binding: form.is_binding,
subject_tags: form.subject_tags
.split(",").map((t) => t.trim()).filter(Boolean),
summary: form.summary.trim(),
headnote: form.headnote.trim(),
key_quote: form.key_quote.trim(),
};
if (form.decision_date) patch.decision_date = form.decision_date;
// case_number is the canonical identifier. Editing it is safe —
// halachot/chunks reference case_law_id (UUID), not the case_number
// text — so a wrong id captured at upload can be corrected here.
if (form.case_number.trim()) patch.case_number = form.case_number.trim();
await update.mutateAsync({ id: caseLawId, patch });
toast.success("נשמר");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
const onRequestMetadata = async () => {
if (!caseLawId) return;
try {
await requestMetadata.mutateAsync(caseLawId);
toast.success(
"סומן לחילוץ מטא-דאטה. הריצי מ-Claude Code: precedent_process_pending",
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onOpenChange(false); }}>
<DialogContent
className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0"
dir="rtl"
>
<DialogHeader className="px-6 pt-6">
<DialogTitle className="text-navy">עריכת פרטי פסיקה</DialogTitle>
<DialogDescription className="text-ink-muted">
כל השדות ניתנים לעריכה, כולל מספר התיק (המזהה הייחודי).
כפתור &quot;חלץ מטא-דאטה&quot; שולח בקשה לתור מקומי שאני מרוקן
מ-Claude Code (ה-LLM רץ מקומית עם <code>claude session</code>,
לא ב-API).
</DialogDescription>
</DialogHeader>
{isPending || !record ? (
<div className="px-6 pb-6 mt-4 space-y-3">
{[...Array(6)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : (
<>
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
<div className="rounded-lg border border-rule bg-rule-soft/40 p-3 flex items-end gap-3">
<div className="flex-1 min-w-0 space-y-1">
<Label htmlFor="case-number" className="text-[0.78rem] text-ink-muted">
מספר תיק (מזהה ייחודי)
</Label>
<Input
id="case-number" value={form.case_number}
onChange={(e) => setForm({ ...form, case_number: e.target.value })}
className="font-mono text-sm" dir="ltr"
placeholder="8027-25"
/>
</div>
<Button
type="button" size="sm" variant="outline"
onClick={onRequestMetadata}
disabled={requestMetadata.isPending}
className="shrink-0"
title="שולח בקשה לחילוץ מטא-דאטה לתור המקומי"
>
<Sparkles className="w-3.5 h-3.5 me-1" />
חלץ מטא-דאטה
</Button>
</div>
<div className="space-y-1">
<Label htmlFor="citation-formatted">
מראה מקום (כללי הציטוט האחיד)
</Label>
<Textarea
id="citation-formatted"
value={form.citation_formatted}
onChange={(e) =>
setForm({ ...form, citation_formatted: e.target.value })
}
rows={3}
dir="rtl"
className="font-mono text-sm"
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ&apos; הוועדה המקומית** (נבו 1.2.2025)'
/>
<p className="text-[0.7rem] text-ink-muted">
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה. שדה זה משמש את כפתור ההעתקה בעמוד הפסיקה.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="case-name">שם קצר</Label>
<Input id="case-name" value={form.case_name}
onChange={(e) => setForm({ ...form, case_name: e.target.value })}
placeholder="ערר 403/17 / אהרון ברק" />
</div>
<div className="space-y-1">
<Label htmlFor="court">ערכאה</Label>
<Input id="court" value={form.court}
onChange={(e) => setForm({ ...form, court: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="district">מחוז</Label>
<Select value={form.district || "_none"}
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
<SelectTrigger id="district"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
{/* Preserve legacy free-text values that don't match any
known district (e.g. older imports with typos). */}
{form.district && !DISTRICTS.some((d) => d.value === form.district) && (
<SelectItem value={form.district}>{form.district}</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="chair-name">יו&quot;ר</Label>
<Input id="chair-name" value={form.chair_name}
onChange={(e) => setForm({ ...form, chair_name: e.target.value })}
placeholder="" />
</div>
<div className="space-y-1">
<Label htmlFor="date">תאריך</Label>
<Input id="date" type="date" value={form.decision_date}
onChange={(e) => setForm({ ...form, decision_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="appeal-subtype">תת-סוג</Label>
<Input id="appeal-subtype" value={form.appeal_subtype}
onChange={(e) => setForm({ ...form, appeal_subtype: e.target.value })}
placeholder="תכנית רחביה / סופיות ההחלטה" />
</div>
</div>
<div className="space-y-1">
<Label>תחום</Label>
<div className="flex gap-4 flex-wrap">
{PRACTICE_AREAS.map((a) => (
<label key={a.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="practice_area" value={a.value}
checked={form.practice_area === a.value}
onChange={() => setForm({ ...form, practice_area: a.value as PracticeArea })} />
<span className="text-sm">{a.label}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="source-type">סוג מקור</Label>
<Select value={form.source_type || "_none"}
onValueChange={(v) => setForm({ ...form, source_type: v === "_none" ? "" : v as SourceType })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{SOURCE_TYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="precedent-level">רמת תקדים</Label>
<Select value={form.precedent_level || "_none"}
onValueChange={(v) => setForm({ ...form, precedent_level: v === "_none" ? "" : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
<Input id="tags" value={form.subject_tags}
onChange={(e) => setForm({ ...form, subject_tags: e.target.value })}
placeholder="חניה, קווי בניין, שיקול דעת" />
</div>
<div className="space-y-1">
<Label htmlFor="summary">תקציר (2-3 משפטים)</Label>
<Textarea id="summary" value={form.summary} rows={3} dir="rtl"
onChange={(e) => setForm({ ...form, summary: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">Headnote (משפט-שניים)</Label>
<Textarea id="headnote" value={form.headnote} rows={2} dir="rtl"
onChange={(e) => setForm({ ...form, headnote: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="key-quote">ציטוט מרכזי</Label>
<Textarea id="key-quote" value={form.key_quote} rows={3} dir="rtl"
onChange={(e) => setForm({ ...form, key_quote: e.target.value })} />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.is_binding}
onChange={(e) => setForm({ ...form, is_binding: e.target.checked })} />
<span className="text-sm">הלכה מחייבת (binding)</span>
<span className="text-[0.7rem] text-ink-muted">
בדרך כלל רק עליון/מנהלי. ועדות ערר אחרות = לא מחייב.
</span>
</label>
<div className="flex gap-2 justify-end pt-2 border-t border-rule-soft">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)} disabled={update.isPending}>
ביטול
</Button>
<Button type="submit" disabled={update.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
<Save className="w-4 h-4 me-1" />
שמור
</Button>
</div>
</form>
<div className="px-6 pb-8 pt-2 border-t border-rule">
<ExtractedHalachotSection halachot={record.halachot ?? []} />
</div>
</>
)}
</DialogContent>
</Dialog>
);
}