feat(precedents): metadata auto-fill, edit sheet, persuasive extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
309
web-ui/src/components/precedents/precedent-edit-sheet.tsx
Normal file
309
web-ui/src/components/precedents/precedent-edit-sheet.tsx
Normal 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">
|
||||
כל השדות ניתנים לעריכה חוץ ממראה המקום (מזהה ייחודי).
|
||||
כפתור "חלץ מטא-דאטה אוטומטית" מנתח את הטקסט וממלא רק שדות ריקים.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user