feat(upload): חסימת כפילות בהעלאת פסיקה + banner עם אפשרויות
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
15
web/app.py
15
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}")
|
||||
|
||||
Reference in New Issue
Block a user