diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 2269425..fd9b4c3 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2615,6 +2615,23 @@ async def get_case_law(case_law_id: UUID) -> dict | None: return _row_to_case_law(row) if row else None +async def get_external_case_law_by_citation(citation: str) -> dict | None: + """Return the first external_upload row whose case_number matches citation, or None.""" + pool = await get_pool() + row = await pool.fetchrow( + """ + SELECT id, case_number, case_name, court, date, + halacha_extraction_status, source_kind, created_at + FROM case_law + WHERE case_number = $1 + AND source_kind = 'external_upload' + LIMIT 1 + """, + citation, + ) + return _row_to_case_law(row) if row else None + + async def mark_indexed(case_law_id: UUID) -> None: """Mark a case_law row's embeddings as built from its current content (FU-3). diff --git a/web-ui/src/components/precedents/precedent-upload-sheet.tsx b/web-ui/src/components/precedents/precedent-upload-sheet.tsx index 1162513..1b35191 100644 --- a/web-ui/src/components/precedents/precedent-upload-sheet.tsx +++ b/web-ui/src/components/precedents/precedent-upload-sheet.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Upload, Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { Upload, Loader2, CheckCircle2, AlertCircle, Trash2, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -16,10 +16,12 @@ import { } from "@/components/ui/select"; import { Progress } from "@/components/ui/progress"; import { - useUploadPrecedent, useUploadInternalDecision, libraryKeys, + useUploadPrecedent, useUploadInternalDecision, useDeletePrecedent, + useRequestHalachotExtraction, libraryKeys, isCommitteeCitation, COMMITTEE_DISTRICTS, type PracticeArea, type SourceType, type CommitteeDistrict, } from "@/lib/api/precedent-library"; +import { ApiError } from "@/lib/api/client"; import { useProgress } from "@/lib/api/documents"; import { PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, @@ -27,6 +29,16 @@ import { const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md"; +type DuplicateConflict = { + error: string; + case_law_id: string; + citation: string; + case_name: string; + court: string; + date: string | null; + halacha_extraction_status: string; +}; + type Props = { open: boolean; onOpenChange: (open: boolean) => void; @@ -46,25 +58,21 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) { 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(""); - // Canonical identifier for committee decisions (e.g. "8027-25"), kept - // distinct from the citation (מראה-מקום). Previously the citation was sent - // as case_number, polluting the identifier. const [caseNumber, setCaseNumber] = useState(""); const isCommittee = isCommitteeCitation(citation); const [taskId, setTaskId] = useState(null); + const [conflict, setConflict] = useState(null); + const upload = useUploadPrecedent(); const uploadInternal = useUploadInternalDecision(); + const deletePrecedent = useDeletePrecedent(); + const requestHalachot = useRequestHalachotExtraction(); 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 @@ -74,16 +82,11 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) { // 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); + setHeadnote(""); setIsBinding(true); setTaskId(null); setConflict(null); // eslint-disable-next-line react-hooks/set-state-in-effect setChairName(""); setDistrict(""); setCaseNumber(""); }, [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 }); @@ -99,73 +102,72 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) { 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); + if (!file) { toast.error("בחר קובץ"); return; } + if (!citation.trim()) { toast.error("מראה המקום (citation) חובה"); return; } + setConflict(null); + + const tags = subjectTags.split(",").map((t) => t.trim()).filter(Boolean); try { if (isCommittee) { - if (!caseNumber.trim()) { - toast.error("מספר תיק (מזהה ייחודי) חובה להחלטת ועדת ערר"); - return; - } - if (!chairName.trim()) { - toast.error("שם יו\"ר חובה להחלטת ועדת ערר"); - return; - } - if (!district) { - toast.error("מחוז חובה להחלטת ועדת ערר"); - return; - } + if (!caseNumber.trim()) { toast.error("מספר תיק חובה להחלטת ועדת ערר"); return; } + if (!chairName.trim()) { toast.error("שם יו\"ר חובה להחלטת ועדת ערר"); return; } + if (!district) { toast.error("מחוז חובה להחלטת ועדת ערר"); return; } const res = await uploadInternal.mutateAsync({ - file, - case_number: caseNumber.trim(), - citation: 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(), + file, case_number: caseNumber.trim(), citation: 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, 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(), + 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) { + if (err instanceof ApiError && err.status === 409) { + const body = err.body as { detail?: DuplicateConflict } | null; + if (body?.detail?.error === "duplicate_external_upload") { + setConflict(body.detail!); + return; + } + } toast.error(err instanceof Error ? err.message : "כשל בהעלאה"); } }; + const handleDelete = async () => { + if (!conflict) return; + if (!window.confirm("למחוק את הרשומה הקיימת? פעולה זו תמחק גם את כל ההלכות שחולצו ממנה.")) return; + try { + await deletePrecedent.mutateAsync(conflict.case_law_id); + toast.success("הרשומה נמחקה — כעת ניתן להעלות מחדש"); + setConflict(null); + } catch (e) { + toast.error(e instanceof Error ? e.message : "שגיאה במחיקה"); + } + }; + + const handleReextract = async () => { + if (!conflict) return; + try { + await requestHalachot.mutateAsync(conflict.case_law_id); + toast.success("חילוץ הלכות הועמד בתור — ההלכות החדשות יופיעו בתור האישור"); + qc.invalidateQueries({ queryKey: libraryKeys.all }); + onOpenChange(false); + } catch (e) { + toast.error(e instanceof Error ? e.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; @@ -183,6 +185,60 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) { + {/* ─── Conflict banner ─── */} + {conflict && ( +
+
+ +
+

הפסיקה כבר קיימת במאגר

+

+ {conflict.citation} + {conflict.case_name && ` — ${conflict.case_name}`} + {conflict.court && ` · ${conflict.court}`} + {conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`} +

+ {conflict.halacha_extraction_status && ( +

+ סטטוס חילוץ הלכות: {conflict.halacha_extraction_status} +

+ )} +
+
+
+ + + +
+
+ )} +
{/* File */}
@@ -199,7 +255,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) { setCitation(e.target.value)} + onChange={(e) => { setCitation(e.target.value); setConflict(null); }} placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`} disabled={isProcessing} dir="rtl" /> diff --git a/web/app.py b/web/app.py index d4b3def..b9195ba 100644 --- a/web/app.py +++ b/web/app.py @@ -5328,6 +5328,21 @@ async def precedent_library_upload( if not citation.strip(): raise HTTPException(400, "citation חובה") + # Reject re-upload of an already-manually-ingested citation so the + # chair can consciously choose between deletion and re-extraction. + existing = await db.get_external_case_law_by_citation(citation.strip()) + if existing: + raise HTTPException(409, detail={ + "error": "duplicate_external_upload", + "case_law_id": str(existing["id"]), + "citation": existing.get("case_number") or citation.strip(), + "case_name": existing.get("case_name") or "", + "court": existing.get("court") or "", + "date": (existing["date"].isoformat() + if existing.get("date") else None), + "halacha_extraction_status": existing.get("halacha_extraction_status") or "", + }) + suffix = Path(file.filename or "").suffix.lower() if suffix not in ALLOWED_EXTENSIONS: raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")