Files
legal-ai/web-ui/src/components/precedents/library-list-panel.tsx
Chaim 73a79ea7e8
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
feat(precedents): metadata auto-fill, edit sheet, persuasive extraction
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>
2026-05-03 10:19:35 +00:00

257 lines
9.2 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 { Trash2, Plus, RefreshCw, Pencil } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
usePrecedents,
useDeletePrecedent,
useReExtractHalachot,
type Precedent,
type PracticeArea,
} 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 "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") {
return <Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">נכשל</Badge>;
}
if (p.extraction_status !== "completed") {
return <Badge variant="outline" className="bg-rule-soft text-ink-muted">בעיבוד</Badge>;
}
if (p.halacha_extraction_status !== "completed") {
return <Badge variant="outline" className="bg-gold-wash text-gold-deep">מחלץ הלכות</Badge>;
}
if (p.halachot_count === 0) {
return <Badge variant="outline">ללא הלכות</Badge>;
}
return (
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40"
>
{p.approved_count}/{p.halachot_count} מאושרות
</Badge>
);
}
function PrecedentRow({
p, onEdit,
}: {
p: Precedent;
onEdit: (id: string) => void;
}) {
const del = useDeletePrecedent();
const reExtract = useReExtractHalachot();
const onDelete = async () => {
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
try {
await del.mutateAsync(p.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const onReExtract = async () => {
try {
await reExtract.mutateAsync(p.id);
toast.success("חילוץ הלכות החל");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30">
<TableCell className="font-semibold text-navy" dir="ltr">
{p.case_number}
</TableCell>
<TableCell className="text-ink">
<div className="font-medium">{p.case_name || "—"}</div>
<div className="text-[0.72rem] text-ink-muted">{p.court || "—"}</div>
</TableCell>
<TableCell className="text-ink-muted">{formatDate(p.date)}</TableCell>
<TableCell>
{p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)}
</Badge>
) : (
<span className="text-ink-light"></span>
)}
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.precedent_level || "—"}
</TableCell>
<TableCell>
<StatusPill p={p} />
</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}
aria-label="חלץ הלכות מחדש"
title="חלץ הלכות מחדש"
className="text-ink-muted hover:text-navy"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending}
aria-label={`מחק את ${p.case_number}`}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
export function LibraryListPanel() {
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
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,
precedentLevel: precedentLevel || undefined,
search: search.trim() || undefined,
limit: 200,
});
return (
<div className="space-y-4">
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="עע&quot;מ 3975/22"
dir="rtl"
/>
</div>
<div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label>
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-[170px]">
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
<Select value={precedentLevel || "_all"} onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
<Plus className="w-4 h-4 me-1" />
העלאת פסיקה
</Button>
</div>
{error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">תחום</TableHead>
<TableHead className="text-navy text-right">רמה</TableHead>
<TableHead className="text-navy text-right">הלכות</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
[...Array(5)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(7)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))
) : !data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-10">
אין פסיקה בקורפוס. העלה את פסק הדין הראשון.
</TableCell>
</TableRow>
) : (
data.items.map((p) => (
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} />
))
)}
</TableBody>
</Table>
</div>
)}
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
<PrecedentEditSheet
caseLawId={editingId}
onOpenChange={(open) => { if (!open) setEditingId(null); }}
/>
</div>
);
}