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>
257 lines
9.2 KiB
TypeScript
257 lines
9.2 KiB
TypeScript
"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="עע"מ 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>
|
||
);
|
||
}
|