From 034b609bd341fc86532bb0755d6e23783bf5c6f8 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 14:52:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20FU-14=20GAP-52=20=E2=80=94=20idemp?= =?UTF-8?q?otency=20=D7=A2=D7=9C=20case=5Fcreate/precedent=5Fattach/docume?= =?UTF-8?q?nt=5Fupload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/spec/X9-mcp-tool-contract.md | 4 +-- docs/spec/gap-audit.md | 3 +- mcp-server/src/legal_mcp/services/db.py | 29 ++++++++++++++++++-- mcp-server/src/legal_mcp/tools/cases.py | 7 +++++ mcp-server/src/legal_mcp/tools/documents.py | 15 ++++++++++ mcp-server/src/legal_mcp/tools/precedents.py | 8 ++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/spec/X9-mcp-tool-contract.md b/docs/spec/X9-mcp-tool-contract.md index 793e7a1..dfca590 100644 --- a/docs/spec/X9-mcp-tool-contract.md +++ b/docs/spec/X9-mcp-tool-contract.md @@ -56,8 +56,8 @@ Zalando *RESTful API Guidelines* | סטטוס: verified [G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent). **הנדסי.** **מקורות:** Stripe — *Idempotent requests* (https://docs.stripe.com/api/idempotent_requests) · Kleppmann *DDIA* (idempotence) · IETF — *Idempotency-Key header* draft (https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) | סטטוס: verified -**אכיפה:** upsert/ON CONFLICT בכלי-מוטציה. **כיום חלקי** — `missing_precedent_create`/`precedent_link_cases`/`extract_internal_citations` idempotent; `case_create`/`document_upload`/`precedent_attach` לא. -**הפרה ידועה:** [gap-audit GAP-52](gap-audit.md). +**אכיפה:** upsert/ON CONFLICT (או בדיקת-מפתח ברמת-אפליקציה) בכלי-מוטציה. **GAP-52 ✅ נסגר (2026-06-06):** `case_create` (מפתח case_number, UNIQUE), `precedent_attach` (מפתח case_id+section_id+citation+quote), `document_upload` (מפתח case_id+SHA-256 של הקובץ — מדלג על OCR/embed כפול) — כולם מחזירים את הקיים במקום כפילות. נבחרה בדיקת-מפתח ברמת-אפליקציה (לא UNIQUE-constraint) כדי לא לשבור startup על נתונים-קיימים כפולים. קודמים: `missing_precedent_create`/`precedent_link_cases`/`extract_internal_citations`. +**הפרה ידועה:** — ### INV-TOOL4: סימטריית extract/get + persistence **כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד). diff --git a/docs/spec/gap-audit.md b/docs/spec/gap-audit.md index 47132ef..c85ffd1 100644 --- a/docs/spec/gap-audit.md +++ b/docs/spec/gap-audit.md @@ -198,7 +198,8 @@ ### FU-14 — חוזה כלי-ה-MCP - **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1–TOOL5 · **effort:** L · **תלויות:** FU-1 - **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT -- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה). נותר: GAP-45 (status-tool), GAP-48 (envelope), GAP-49/50 (מיזוג+rename — שובר), GAP-51 (set_outcome enum SSoT), GAP-52 (idempotency). +- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה). +- **סטטוס חלקי (פרוסה 2, 2026-06-06):** ✅ **GAP-52** (INV-TOOL3 idempotency) — `case_create`/`precedent_attach`/`document_upload` מחזירים קיים במקום כפילות (בדיקת-מפתח ברמת-אפליקציה; document_upload לפי SHA-256 → מדלג OCR/embed כפול). נותר: GAP-45 (status-tool), GAP-51 (set_outcome enum SSoT — דורש הכרעת-domain), GAP-48 (envelope), GAP-49/50 (מיזוג+rename — שובר). ### FU-15 — deploy/env/secrets - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1–ENV5 · **effort:** M · **תלויות:** — diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 568c9eb..2ed7bf0 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -105,6 +105,11 @@ CREATE TABLE IF NOT EXISTS documents ( created_at TIMESTAMPTZ DEFAULT now() ); +-- INV-TOOL3 / GAP-52: SHA-256 of the uploaded file bytes, for idempotent upload +-- (re-uploading the same file to a case returns the existing document). Empty +-- default = legacy rows with unknown hash; never matched as a duplicate. +ALTER TABLE documents ADD COLUMN IF NOT EXISTS content_hash text NOT NULL DEFAULT ''; + CREATE TABLE IF NOT EXISTS document_chunks ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), document_id UUID REFERENCES documents(id) ON DELETE CASCADE, @@ -1471,19 +1476,37 @@ async def create_document( title: str, file_path: str, page_count: int | None = None, + content_hash: str = "", ) -> dict: pool = await get_pool() doc_id = uuid4() async with pool.acquire() as conn: await conn.execute( - """INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count) - VALUES ($1, $2, $3, $4, $5, $6)""", - doc_id, case_id, doc_type, title, file_path, page_count, + """INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count, content_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + doc_id, case_id, doc_type, title, file_path, page_count, content_hash, ) row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id) return _row_to_doc(row) +async def get_document_by_hash(case_id: UUID, content_hash: str) -> dict | None: + """Return an existing document for this case with the same file hash, or None. + + INV-TOOL3 / GAP-52: deterministic key for idempotent upload. Empty hashes + (legacy rows) are never matched. + """ + if not content_hash: + return None + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM documents WHERE case_id = $1 AND content_hash = $2 LIMIT 1", + case_id, content_hash, + ) + return _row_to_doc(row) if row else None + + async def update_document(doc_id: UUID, **fields) -> None: if not fields: return diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 8e61a19..d507db0 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -153,6 +153,13 @@ async def case_create( ריק = יוסק אוטומטית ממספר התיק proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject. """ + # INV-TOOL3 / GAP-52: idempotent on case_number (already UNIQUE in schema). + # Re-creating an existing case returns it instead of raising a unique-violation. + _existing = await db.get_case_by_number(case_number) + if _existing: + _existing["idempotent_existing"] = True + return json.dumps(_existing, default=str, ensure_ascii=False, indent=2) + from datetime import date as date_type h_date = None diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 7b81eba..54d6b06 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -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) diff --git a/mcp-server/src/legal_mcp/tools/precedents.py b/mcp-server/src/legal_mcp/tools/precedents.py index 90e3b79..a17406c 100644 --- a/mcp-server/src/legal_mcp/tools/precedents.py +++ b/mcp-server/src/legal_mcp/tools/precedents.py @@ -43,6 +43,14 @@ async def precedent_attach( except ValueError: return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False) + # INV-TOOL3 / GAP-52: idempotent on (case_id, section_id, citation, quote). + # Re-attaching the same quote to the same section returns the existing row. + for _p in await db.list_case_precedents(UUID(case["id"])): + if (_p.get("citation") == citation and _p.get("quote") == quote + and (_p.get("section_id") or None) == (section_id or None)): + _p["idempotent_existing"] = True + return json.dumps(_p, ensure_ascii=False, indent=2, default=str) + row = await db.create_case_precedent( case_id=UUID(case["id"]), quote=quote, -- 2.49.1