feat: external precedent library with auto halacha extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
Adds a third corpus of legal authority distinct from style_corpus (Daphna's prior decisions for voice) and case_precedents (chair-attached quotes per case). The new corpus holds chair-uploaded court rulings and other appeals committee decisions, with binding rules (הלכות) extracted automatically and queued for chair approval. Pipeline (web/app.py + services/precedent_library.py): file → extract → chunk → Voyage embed → halacha_extractor → store + publish progress over the existing Redis SSE channel. Schema V7 (services/db.py): extends case_law with source_kind + extraction status fields under a CHECK constraint pinning practice_area to the three appeals committee domains (rishuy_uvniya, betterment_levy, compensation_197). New precedent_chunks (vector(1024)) and halachot tables (vector(1024) over rule_statement, IVFFlat indexes, gin on practice_areas/subject_tags). Halachot start as pending_review; only approved/published rows are visible to search_precedent_library. Agents: legal-writer, legal-researcher, legal-analyst, legal-ceo, legal-qa get search_precedent_library. legal-writer prompt explains the three-corpus distinction and CREAC use; legal-qa now verifies that every cited halacha resolves to an approved row in the corpus. UI: /precedents page with four tabs — library / semantic search / pending review (J/K nav, A/R/E shortcuts, badge count) / stats. Reuses the existing upload-sheet progress + SSE pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
235
web-ui/src/components/precedents/library-list-panel.tsx
Normal file
235
web-ui/src/components/precedents/library-list-panel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2, Plus, RefreshCw } 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";
|
||||
|
||||
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 }: { p: Precedent }) {
|
||||
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={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 { 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} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user