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,290 @@
"use client";
import { useEffect, useState } from "react";
import { Upload, Loader2, CheckCircle2, AlertCircle } 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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { useUploadPrecedent, 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";
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
const [file, setFile] = useState<File | null>(null);
const [citation, setCitation] = useState("");
const [caseName, setCaseName] = useState("");
const [court, setCourt] = useState("");
const [decisionDate, setDecisionDate] = useState("");
const [sourceType, setSourceType] = useState<SourceType>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [subjectTags, setSubjectTags] = useState("");
const [headnote, setHeadnote] = useState("");
const [isBinding, setIsBinding] = useState(true);
const [taskId, setTaskId] = useState<string | null>(null);
const upload = useUploadPrecedent();
const progress = useProgress(taskId);
// Reset form when the sheet closes — fields, file input, and any in-flight
// task subscription. We accept the cascade-render warning because resetting
// form state on close is exactly the intended side effect.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null); setCitation(""); setCaseName(""); setCourt("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setDecisionDate(""); setSourceType(""); setPrecedentLevel("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadnote(""); setIsBinding(true); setTaskId(null);
}, [open]);
// Auto-close on completion
useEffect(() => {
if (progress?.status === "completed") {
toast.success("הפסיקה הוכנסה לקורפוס. ההלכות ממתינות לאישור.");
const t = window.setTimeout(() => onOpenChange(false), 1200);
return () => window.clearTimeout(t);
}
if (progress?.status === "failed") {
toast.error(`כשל בעיבוד: ${progress.error || "שגיאה לא ידועה"}`);
}
}, [progress, onOpenChange]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
toast.error("בחר קובץ");
return;
}
if (!citation.trim()) {
toast.error("מראה המקום (citation) חובה");
return;
}
if (!practiceArea) {
toast.error("בחר תחום משפט");
return;
}
try {
const tags = subjectTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const res = await upload.mutateAsync({
file,
citation: citation.trim(),
case_name: caseName.trim(),
court: court.trim(),
decision_date: decisionDate || undefined,
source_type: sourceType || undefined,
precedent_level: precedentLevel || undefined,
practice_area: practiceArea,
appeal_subtype: appealSubtype.trim(),
subject_tags: tags,
is_binding: isBinding,
headnote: headnote.trim(),
});
setTaskId(res.task_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : "כשל בהעלאה");
}
};
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<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">
הקובץ יעבור חילוץ טקסט, יצירת embeddings, וחילוץ הלכות אוטומטי.
ההלכות יחכו לאישורך לפני שהן זמינות לסוכני הכתיבה.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
{/* File */}
<div className="space-y-1">
<Label htmlFor="file">קובץ (PDF / DOCX / DOC / RTF / TXT / MD)</Label>
<Input
id="file" type="file" accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={isProcessing}
/>
</div>
{/* Citation */}
<div className="space-y-1">
<Label htmlFor="citation">מראה המקום (חובה)</Label>
<Input
id="citation" value={citation}
onChange={(e) => setCitation(e.target.value)}
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
disabled={isProcessing} dir="rtl"
/>
</div>
{/* Two-col grid */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="case-name">שם קצר</Label>
<Input id="case-name" value={caseName}
onChange={(e) => setCaseName(e.target.value)}
placeholder="ב. קרן-נכסים" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="court">ערכאה</Label>
<Input id="court" value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="date">תאריך החלטה</Label>
<Input id="date" type="date" value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
<Input id="appeal-subtype" value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
</div>
</div>
{/* Practice area (required radio) */}
<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={practiceArea === a.value}
onChange={() => setPracticeArea(a.value as PracticeArea)}
disabled={isProcessing}
/>
<span className="text-sm text-ink">{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={sourceType || "_none"}
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></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={precedentLevel || "_none"}
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></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={subjectTags}
onChange={(e) => setSubjectTags(e.target.value)}
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">תקציר / headnote (אופציונלי)</Label>
<Textarea id="headnote" value={headnote} rows={2}
onChange={(e) => setHeadnote(e.target.value)}
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing} />
<span className="text-sm">הלכה מחייבת</span>
</label>
{isProcessing && (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-navy">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{(progress as { step?: string } | null)?.step || stage || "מעבד"}</span>
</div>
<Progress value={percent} className="h-1.5" />
</div>
)}
{progress?.status === "completed" && (
<div className="rounded-lg border border-gold/40 bg-gold-wash p-4 flex items-center gap-2 text-gold-deep text-sm">
<CheckCircle2 className="w-4 h-4" />
נכנס לקורפוס. ההלכות ממתינות בתור האישור.
</div>
)}
{progress?.status === "failed" && (
<div className="rounded-lg border border-danger/40 bg-danger-bg p-4 flex items-center gap-2 text-danger text-sm">
<AlertCircle className="w-4 h-4" />
{progress.error || "שגיאה"}
</div>
)}
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)} disabled={upload.isPending}>
ביטול
</Button>
<Button type="submit"
disabled={upload.isPending || isProcessing}
className="bg-navy text-parchment hover:bg-navy-soft">
<Upload className="w-4 h-4 me-1" />
העלה
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}