feat: external precedent library with auto halacha extraction
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:
2026-05-03 08:38:18 +00:00
parent a6edb75bbf
commit 7ee90dce31
23 changed files with 3853 additions and 67 deletions

View 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="עע&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} />)
)}
</TableBody>
</Table>
</div>
)}
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
</div>
);
}