feat(upload): חסימת כפילות בהעלאת פסיקה + banner עם אפשרויות
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s

- בקאנד: GET לפני ה-async task — אם citation כבר קיים כ-external_upload מחזיר 409
- DB: get_external_case_law_by_citation — lookup לפי citation + source_kind
- פרונט: banner אדום עם פרטי הרשומה הקיימת ושני כפתורות:
  • "הפעל חילוץ מחדש" — request-halachot ל-ID הקיים וסגירת הטופס
  • "מחק את הרשומה" — DELETE עם confirm, ניקוי conflict לאחר מכן

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:11:33 +00:00
parent c83d0162ca
commit 68a77c11b6
3 changed files with 155 additions and 67 deletions

View File

@@ -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).

View File

@@ -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<CommitteeDistrict | "">("");
// 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<string | null>(null);
const [conflict, setConflict] = useState<DuplicateConflict | null>(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) {
</SheetDescription>
</SheetHeader>
{/* ─── Conflict banner ─── */}
{conflict && (
<div className="mx-6 mt-4 rounded-lg border border-danger bg-danger-bg p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-danger shrink-0 mt-0.5" />
<div className="space-y-1 flex-1 min-w-0">
<p className="font-semibold text-danger text-sm">הפסיקה כבר קיימת במאגר</p>
<p className="text-danger/80 text-[0.78rem]" dir="rtl">
{conflict.citation}
{conflict.case_name && `${conflict.case_name}`}
{conflict.court && ` · ${conflict.court}`}
{conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`}
</p>
{conflict.halacha_extraction_status && (
<p className="text-danger/60 text-[0.72rem]">
סטטוס חילוץ הלכות: {conflict.halacha_extraction_status}
</p>
)}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
size="sm" variant="ghost"
onClick={() => setConflict(null)}
className="text-ink-muted"
>
ביטול
</Button>
<Button
size="sm" variant="outline"
onClick={handleReextract}
disabled={requestHalachot.isPending}
className="border-danger/40 text-danger hover:bg-danger-bg"
>
{requestHalachot.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
: <RefreshCw className="w-3.5 h-3.5 me-1" />}
הפעל חילוץ מחדש
</Button>
<Button
size="sm"
onClick={handleDelete}
disabled={deletePrecedent.isPending}
className="bg-danger text-white hover:bg-danger/90"
>
{deletePrecedent.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
: <Trash2 className="w-3.5 h-3.5 me-1" />}
מחק את הרשומה
</Button>
</div>
</div>
)}
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
{/* File */}
<div className="space-y-1">
@@ -199,7 +255,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
<Label htmlFor="citation">מראה המקום (חובה)</Label>
<Input
id="citation" value={citation}
onChange={(e) => setCitation(e.target.value)}
onChange={(e) => { setCitation(e.target.value); setConflict(null); }}
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
disabled={isProcessing} dir="rtl"
/>

View File

@@ -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}")