feat(mcp): FU-14 GAP-52 — idempotency על case_create/precedent_attach/document_upload

INV-TOOL3 (idempotency על מפתח דטרמיניסטי). כל שלושת הכלים מחזירים את הרשומה
הקיימת במקום ליצור כפילות:

- case_create — מפתח case_number (כבר UNIQUE ב-schema): מחזיר את התיק הקיים
  במקום unique-violation.
- precedent_attach — מפתח (case_id, section_id, citation, quote): צירוף חוזר
  של אותו ציטוט לאותו סעיף מחזיר את הקיים.
- document_upload — מפתח (case_id, SHA-256 של בייטי הקובץ): העלאה חוזרת של אותו
  קובץ מחזירה את המסמך הקיים ו**מדלגת על copy+OCR+embed** (החלק היקר). נוספה
  עמודת documents.content_hash (תוספתי, DEFAULT '') + get_document_by_hash.

נבחרה בדיקת-מפתח ברמת-אפליקציה (SELECT-לפני-INSERT) ולא UNIQUE-constraint —
כדי לא לשבור startup אם קיימים נתונים-כפולים legacy. אין מיגרציה הרסנית.

עודכנו docs/spec/X9 (INV-TOOL3 ) ו-gap-audit (GAP-52 , פרוסה 2).
py_compile עבר על 4 קבצי הקוד. אימות runtime (restart MCP server) נדחה עד
שהחילוץ הפעיל יסתיים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:52:33 +00:00
parent b53d65c1f6
commit 034b609bd3
6 changed files with 60 additions and 6 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
@@ -37,6 +38,19 @@ async def document_upload(
if not title:
title = source.stem
# INV-TOOL3 / GAP-52: idempotent on (case_id, file content hash). Re-uploading
# the same bytes returns the existing document and skips re-copy + re-OCR +
# re-embed (the expensive part).
content_hash = hashlib.sha256(source.read_bytes()).hexdigest()
existing_doc = await db.get_document_by_hash(case_id, content_hash)
if existing_doc:
return json.dumps({
"status": "exists",
"message": f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.",
"document": existing_doc,
"idempotent_existing": True,
}, ensure_ascii=False, indent=2, default=str)
# Copy file to case directory
case_dir = config.find_case_dir(case_number) / "documents" / "originals"
case_dir.mkdir(parents=True, exist_ok=True)
@@ -52,6 +66,7 @@ async def document_upload(
doc_type=initial_doc_type,
title=title,
file_path=str(dest),
content_hash=content_hash,
)
# Process document (extract → classify → chunk → embed → store)