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
|
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:
|
async def mark_indexed(case_law_id: UUID) -> None:
|
||||||
"""Mark a case_law row's embeddings as built from its current content (FU-3).
|
"""Mark a case_law row's embeddings as built from its current content (FU-3).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -16,10 +16,12 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
useUploadPrecedent, useUploadInternalDecision, libraryKeys,
|
useUploadPrecedent, useUploadInternalDecision, useDeletePrecedent,
|
||||||
|
useRequestHalachotExtraction, libraryKeys,
|
||||||
isCommitteeCitation, COMMITTEE_DISTRICTS,
|
isCommitteeCitation, COMMITTEE_DISTRICTS,
|
||||||
type PracticeArea, type SourceType, type CommitteeDistrict,
|
type PracticeArea, type SourceType, type CommitteeDistrict,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
|
import { ApiError } from "@/lib/api/client";
|
||||||
import { useProgress } from "@/lib/api/documents";
|
import { useProgress } from "@/lib/api/documents";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
||||||
@@ -27,6 +29,16 @@ import {
|
|||||||
|
|
||||||
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
|
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 = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -46,25 +58,21 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
const [headnote, setHeadnote] = useState("");
|
const [headnote, setHeadnote] = useState("");
|
||||||
const [isBinding, setIsBinding] = useState(true);
|
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 [chairName, setChairName] = useState("");
|
||||||
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
|
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 [caseNumber, setCaseNumber] = useState("");
|
||||||
const isCommittee = isCommitteeCitation(citation);
|
const isCommittee = isCommitteeCitation(citation);
|
||||||
|
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
|
const [conflict, setConflict] = useState<DuplicateConflict | null>(null);
|
||||||
|
|
||||||
const upload = useUploadPrecedent();
|
const upload = useUploadPrecedent();
|
||||||
const uploadInternal = useUploadInternalDecision();
|
const uploadInternal = useUploadInternalDecision();
|
||||||
|
const deletePrecedent = useDeletePrecedent();
|
||||||
|
const requestHalachot = useRequestHalachotExtraction();
|
||||||
const progress = useProgress(taskId);
|
const progress = useProgress(taskId);
|
||||||
const qc = useQueryClient();
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) return;
|
if (open) return;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 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
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 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
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setChairName(""); setDistrict(""); setCaseNumber("");
|
setChairName(""); setDistrict(""); setCaseNumber("");
|
||||||
}, [open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (progress?.status === "completed") {
|
if (progress?.status === "completed") {
|
||||||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||||
@@ -99,73 +102,72 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!file) {
|
if (!file) { toast.error("בחר קובץ"); return; }
|
||||||
toast.error("בחר קובץ");
|
if (!citation.trim()) { toast.error("מראה המקום (citation) חובה"); return; }
|
||||||
return;
|
setConflict(null);
|
||||||
}
|
|
||||||
if (!citation.trim()) {
|
const tags = subjectTags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
toast.error("מראה המקום (citation) חובה");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tags = subjectTags
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isCommittee) {
|
if (isCommittee) {
|
||||||
if (!caseNumber.trim()) {
|
if (!caseNumber.trim()) { toast.error("מספר תיק חובה להחלטת ועדת ערר"); return; }
|
||||||
toast.error("מספר תיק (מזהה ייחודי) חובה להחלטת ועדת ערר");
|
if (!chairName.trim()) { toast.error("שם יו\"ר חובה להחלטת ועדת ערר"); return; }
|
||||||
return;
|
if (!district) { toast.error("מחוז חובה להחלטת ועדת ערר"); return; }
|
||||||
}
|
|
||||||
if (!chairName.trim()) {
|
|
||||||
toast.error("שם יו\"ר חובה להחלטת ועדת ערר");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!district) {
|
|
||||||
toast.error("מחוז חובה להחלטת ועדת ערר");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await uploadInternal.mutateAsync({
|
const res = await uploadInternal.mutateAsync({
|
||||||
file,
|
file, case_number: caseNumber.trim(), citation: citation.trim(),
|
||||||
case_number: caseNumber.trim(),
|
chair_name: chairName.trim(), district, case_name: caseName.trim(),
|
||||||
citation: citation.trim(),
|
court: court.trim(), decision_date: decisionDate || undefined,
|
||||||
chair_name: chairName.trim(),
|
practice_area: practiceArea, appeal_subtype: appealSubtype.trim(),
|
||||||
district,
|
subject_tags: tags, is_binding: false, summary: headnote.trim(),
|
||||||
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);
|
setTaskId(res.task_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await upload.mutateAsync({
|
const res = await upload.mutateAsync({
|
||||||
file,
|
file, citation: citation.trim(), case_name: caseName.trim(),
|
||||||
citation: citation.trim(),
|
court: court.trim(), decision_date: decisionDate || undefined,
|
||||||
case_name: caseName.trim(),
|
source_type: sourceType || undefined, precedent_level: precedentLevel || undefined,
|
||||||
court: court.trim(),
|
practice_area: practiceArea, appeal_subtype: appealSubtype.trim(),
|
||||||
decision_date: decisionDate || undefined,
|
subject_tags: tags, is_binding: isBinding, headnote: headnote.trim(),
|
||||||
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);
|
setTaskId(res.task_id);
|
||||||
} catch (err) {
|
} 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 : "כשל בהעלאה");
|
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 isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
||||||
const isSubmitting = upload.isPending || uploadInternal.isPending;
|
const isSubmitting = upload.isPending || uploadInternal.isPending;
|
||||||
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
||||||
@@ -183,6 +185,60 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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">
|
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
|
||||||
{/* File */}
|
{/* File */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -199,7 +255,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
<Label htmlFor="citation">מראה המקום (חובה)</Label>
|
<Label htmlFor="citation">מראה המקום (חובה)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="citation" value={citation}
|
id="citation" value={citation}
|
||||||
onChange={(e) => setCitation(e.target.value)}
|
onChange={(e) => { setCitation(e.target.value); setConflict(null); }}
|
||||||
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
||||||
disabled={isProcessing} dir="rtl"
|
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():
|
if not citation.strip():
|
||||||
raise HTTPException(400, "citation חובה")
|
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()
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
if suffix not in ALLOWED_EXTENSIONS:
|
if suffix not in ALLOWED_EXTENSIONS:
|
||||||
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
||||||
|
|||||||
Reference in New Issue
Block a user