feat(precedents): metadata auto-fill, edit sheet, persuasive extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s

Three improvements to the precedent library based on usage feedback:

1. Auto-fill metadata at upload time. New service
   precedent_metadata_extractor reads the ruling's full_text and
   suggests case_name (short), summary, headnote, key_quote,
   subject_tags, appeal_subtype. The merge policy fills only empty
   fields, preserving everything the chair typed in the upload form.
   Wired into the ingest pipeline; also exposed as a re-run endpoint
   POST /api/precedent-library/{id}/extract-metadata for existing
   records.

2. Edit sheet in the UI. Pencil icon on each library row opens a
   pre-populated form covering every field. A Sparkles button on the
   sheet runs the metadata extractor on demand and refreshes the
   form. The case_number is read-only because halachot are FK'd to
   it; renaming requires delete + re-upload.

3. Halacha extractor branches on is_binding. Sources marked binding
   (Supreme/Administrative) keep the strict halacha prompt. Non-binding
   sources (other appeals committees, district courts on planning
   matters) get a different prompt that extracts applications,
   interpretive principles, and persuasive conclusions — labeled with
   new rule_types 'application' and 'persuasive'. The fallback also
   widens chunk selection: if the chunker labeled nothing as
   legal_analysis/ruling/conclusion, we now run on all chunks rather
   than returning zero halachot for a usable ruling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 10:19:35 +00:00
parent b51163b67c
commit 73a79ea7e8
10 changed files with 841 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Trash2, Plus, RefreshCw } from "lucide-react";
import { Trash2, Plus, RefreshCw, Pencil } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -22,6 +22,7 @@ import {
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area";
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
import { PrecedentEditSheet } from "./precedent-edit-sheet";
function formatDate(iso: string | null) {
if (!iso) return "—";
@@ -55,7 +56,12 @@ function StatusPill({ p }: { p: Precedent }) {
);
}
function PrecedentRow({ p }: { p: Precedent }) {
function PrecedentRow({
p, onEdit,
}: {
p: Precedent;
onEdit: (id: string) => void;
}) {
const del = useDeletePrecedent();
const reExtract = useReExtractHalachot();
@@ -105,6 +111,14 @@ function PrecedentRow({ p }: { p: Precedent }) {
</TableCell>
<TableCell className="text-end">
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost" size="sm" onClick={() => onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`}
title="ערוך פרטים"
className="text-ink-muted hover:text-navy"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="sm" onClick={onReExtract}
disabled={reExtract.isPending}
@@ -133,6 +147,7 @@ export function LibraryListPanel() {
const [precedentLevel, setPrecedentLevel] = useState("");
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const { data, isPending, error } = usePrecedents({
practiceArea: practiceArea || undefined,
@@ -222,7 +237,9 @@ export function LibraryListPanel() {
</TableCell>
</TableRow>
) : (
data.items.map((p) => <PrecedentRow key={p.id} p={p} />)
data.items.map((p) => (
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} />
))
)}
</TableBody>
</Table>
@@ -230,6 +247,10 @@ export function LibraryListPanel() {
)}
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
<PrecedentEditSheet
caseLawId={editingId}
onOpenChange={(open) => { if (!open) setEditingId(null); }}
/>
</div>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useEffect, useState } from "react";
import { Save, Sparkles, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
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,
useReExtractMetadata,
type PracticeArea,
type SourceType,
} from "@/lib/api/precedent-library";
import { useProgress } from "@/lib/api/documents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
} from "./practice-area";
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 = {
citation: string;
case_name: string;
court: 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 = {
citation: "", case_name: "", court: "", 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 reextractMeta = useReExtractMetadata();
const [form, setForm] = useState<FormState>(EMPTY);
const [metadataTaskId, setMetadataTaskId] = useState<string | null>(null);
const metadataProgress = useProgress(metadataTaskId);
// Hydrate form when the record loads.
useEffect(() => {
if (!record) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
citation: record.case_number || "",
case_name: record.case_name || "",
court: record.court || "",
decision_date: record.date ? record.date.slice(0, 10) : "",
practice_area: (record.practice_area || "") as PracticeArea,
appeal_subtype: 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 || "",
});
}, [record]);
// Auto-close metadata progress on completion + refresh form
useEffect(() => {
if (metadataProgress?.status === "completed") {
toast.success("חילוץ מטא-דאטה הסתיים — השדות עודכנו");
setMetadataTaskId(null);
} else if (metadataProgress?.status === "failed") {
toast.error(`חילוץ מטא-דאטה נכשל: ${metadataProgress.error || ""}`);
setMetadataTaskId(null);
}
}, [metadataProgress]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!caseLawId) return;
try {
const patch: Record<string, unknown> = {
case_name: form.case_name.trim(),
court: form.court.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;
// citation (case_number) is the unique key; we don't allow editing it
// here to avoid orphaning halachot. To rename, delete + re-upload.
await update.mutateAsync({ id: caseLawId, patch });
toast.success("נשמר");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
const onTriggerMetadata = async () => {
if (!caseLawId) return;
try {
const res = await reextractMeta.mutateAsync(caseLawId);
setMetadataTaskId(res.task_id);
toast.message("מחלץ מטא-דאטה ברקע…");
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
const isMetaRunning = metadataTaskId !== null
&& metadataProgress?.status !== "completed"
&& metadataProgress?.status !== "failed";
return (
<Sheet open={open} onOpenChange={(o) => { if (!o) onOpenChange(false); }}>
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto" dir="rtl">
<SheetHeader>
<SheetTitle className="text-navy">עריכת פרטי פסיקה</SheetTitle>
<SheetDescription className="text-ink-muted">
כל השדות ניתנים לעריכה חוץ ממראה המקום (מזהה ייחודי).
כפתור &quot;חלץ מטא-דאטה אוטומטית&quot; מנתח את הטקסט וממלא רק שדות ריקים.
</SheetDescription>
</SheetHeader>
{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-start gap-3">
<div className="flex-1">
<div className="text-[0.78rem] text-ink-muted">מראה מקום (לא ניתן לעריכה)</div>
<div className="text-navy font-mono text-sm break-all" dir="ltr">
{record.case_number}
</div>
</div>
<Button
type="button" size="sm" variant="outline"
onClick={onTriggerMetadata}
disabled={isMetaRunning || reextractMeta.isPending}
className="shrink-0"
>
{isMetaRunning ? (
<Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1" />
)}
חלץ מטא-דאטה אוטומטית
</Button>
</div>
{isMetaRunning && (metadataProgress as { step?: string } | null)?.step && (
<div className="text-[0.78rem] text-ink-muted">
{(metadataProgress as { step?: string }).step}
</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="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>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -350,6 +350,21 @@ export function useReExtractHalachot() {
});
}
export function useReExtractMetadata() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ task_id: string }>(
`/api/precedent-library/${encodeURIComponent(id)}/extract-metadata`,
{ method: "POST" },
),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useHalachotPending(limit = 200) {
return useQuery({
queryKey: libraryKeys.halachotPending(),