Files
legal-ai/web-ui/src/components/precedents/precedent-upload-sheet.tsx
Chaim 1b62fa4af8 fix(precedents): ועדת ערר decisions are never binding (is_binding=false)
מסך העלאת הפסיקה הציג צ'קבוקס "הלכה מחייבת" עם ברירת מחדל true גם
להחלטות ועדת ערר (isCommittee), כך שהלכות שחולצו מהחלטה לא-מחייבת
תויגו rule_type='binding' — בסתירה להגדרה הדוקטרינרית (ועדת ערר =
persuasive בלבד, לא binding כמו עליון/מנהלי).

- מסלול ההגשה של החלטות ועדת ערר שולח כעת is_binding=false תמיד
- הצ'קבוקס ננעל (disabled+unchecked) כשזוהתה החלטת ועדת ערר, עם
  הסבר שההלכות יסומנו persuasive

יישור דוקטרינרי בלבד — אין השפעה downstream על ranking/injection;
rule_type הוא תווית תצוגה, והשער הפונקציונלי הוא review_status.

TaskMaster #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:39:59 +00:00

388 lines
17 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 { useEffect, useState } from "react";
import { Upload, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
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, useUploadInternalDecision, libraryKeys,
isCommitteeCitation, COMMITTEE_DISTRICTS,
type PracticeArea, type SourceType, type CommitteeDistrict,
} 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);
// Appeals-committee decisions go to /api/internal-decisions/upload and
// require chair_name + district. Routing is by citation prefix.
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
const isCommittee = isCommitteeCitation(citation);
const [taskId, setTaskId] = useState<string | null>(null);
const upload = useUploadPrecedent();
const uploadInternal = useUploadInternalDecision();
const progress = useProgress(taskId);
const qc = useQueryClient();
// 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);
// eslint-disable-next-line react-hooks/set-state-in-effect
setChairName(""); setDistrict("");
}, [open]);
// Auto-close on completion + refresh library list/stats so the new
// row appears with up-to-date counts (halachot, approved). The mutation's
// onSuccess fires when POST returns the task_id; we need a second
// invalidation when SSE reports terminal status, otherwise the table
// shows stale data.
useEffect(() => {
if (progress?.status === "completed") {
qc.invalidateQueries({ queryKey: libraryKeys.all });
toast.success("הפסיקה הוכנסה לקורפוס. ההלכות ממתינות לאישור.");
const t = window.setTimeout(() => onOpenChange(false), 1200);
return () => window.clearTimeout(t);
}
if (progress?.status === "failed") {
qc.invalidateQueries({ queryKey: libraryKeys.all });
toast.error(`כשל בעיבוד: ${progress.error || "שגיאה לא ידועה"}`);
}
}, [progress, onOpenChange, qc]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
toast.error("בחר קובץ");
return;
}
if (!citation.trim()) {
toast.error("מראה המקום (citation) חובה");
return;
}
const tags = subjectTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
try {
if (isCommittee) {
if (!chairName.trim()) {
toast.error("שם יו\"ר חובה להחלטת ועדת ערר");
return;
}
if (!district) {
toast.error("מחוז חובה להחלטת ועדת ערר");
return;
}
const res = await uploadInternal.mutateAsync({
file,
case_number: citation.trim(),
chair_name: chairName.trim(),
district,
case_name: caseName.trim(),
court: court.trim(),
decision_date: decisionDate || undefined,
practice_area: practiceArea,
appeal_subtype: appealSubtype.trim(),
subject_tags: tags,
// החלטות ועדת ערר אינן מהוות הלכה מחייבת לפי הגדרה (persuasive בלבד) — לעולם לא binding.
is_binding: false,
summary: headnote.trim(),
});
setTaskId(res.task_id);
return;
}
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 isSubmitting = upload.isPending || uploadInternal.isPending;
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"
/>
{isCommittee && (
<p className="text-[0.72rem] text-ink-muted">
זוהתה כהחלטת ועדת ערר נדרשים שם יו&quot;ר ומחוז (למטה).
</p>
)}
</div>
{isCommittee && (
<div className="grid grid-cols-2 gap-3 rounded-md border border-gold/40 bg-gold-wash/40 p-3">
<div className="space-y-1">
<Label htmlFor="chair-name">שם יו&quot;ר (חובה)</Label>
<Input
id="chair-name" value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder='עו"ד פלוני אלמוני'
disabled={isProcessing} dir="rtl"
/>
</div>
<div className="space-y-1">
<Label htmlFor="district">מחוז (חובה)</Label>
<Select
value={district || "_none"}
onValueChange={(v) =>
setDistrict(v === "_none" ? "" : (v as CommitteeDistrict))
}
disabled={isProcessing}
>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{COMMITTEE_DISTRICTS.map((d) => (
<SelectItem key={d} value={d}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<details className="group rounded-md border border-rule bg-rule-soft/30">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית מהמסמך
</summary>
<div className="space-y-3 border-t border-rule px-3 py-3">
<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>
<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>
{!isCommittee && (
<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 ${isCommittee ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}>
<input type="checkbox" checked={isCommittee ? false : isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing || isCommittee} />
<span className="text-sm">הלכה מחייבת</span>
</label>
{isCommittee && (
<p className="text-[0.72rem] text-ink-muted">
החלטות ועדת ערר אינן מהוות הלכה מחייבת ההלכות שיחולצו יסומנו כ&quot;משכנעת&quot; (persuasive).
</p>
)}
</div>
</details>
{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={isSubmitting}>
ביטול
</Button>
<Button type="submit"
disabled={isSubmitting || isProcessing}
className="bg-navy text-parchment hover:bg-navy-soft">
<Upload className="w-4 h-4 me-1" />
העלה
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}