From 79b9c37301560caf351c3e3565f239de916488d3 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 17:41:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20FU-14=20GAP-48=20=D7=A4=D7=A8?= =?UTF-8?q?=D7=95=D7=A1=D7=94=202=20=E2=80=94=20envelope=20=D7=90=D7=97?= =?UTF-8?q?=D7=99=D7=93=20=D7=9C-11=20=D7=9E=D7=A9=D7=A4=D7=97=D7=95=D7=AA?= =?UTF-8?q?-=D7=9B=D7=9C=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit המשך מיגרציית INV-TOOL1 מעבר למשפחת-החיפוש (#71). הומרו ל-{status,data,message}: precedent_library, citations, internal_decisions, missing_precedents, training_enrichment, precedents, legal_arguments, cases, documents, workflow (~55 כלים). בוטלו 5 עותקי _ok/_err משוכפלים (alias ל-tools/envelope.py — SSoT, G2). עיקרון: envelope-status = הצלחת-הקריאה-לכלי; תוצאה-עסקית (idempotent_existing, noop, completed...) נשמרת בתוך data. err רק לכשל אמיתי (not-found/invalid/exception). תאימות-API: צרכני web/app.py של cases/workflow/precedents חוּוטו דרך envelope_unwrap + בדיקת status=="error"→4xx — תשובת ה-HTTP זהה, web-ui לא מושפע. (documents/legal_arguments/citations/... אינם נצרכים מ-app.py — agent-only.) בדיקות: 182/182 עוברים (test_corpus_constraints עודכן לחוזה החדש). נותר: משפחת drafting (מסלול הפקת-ההחלטה) בפרוסה נפרדת עם שער טסט-ייצוא. Invariants: מקדם INV-TOOL1 + G2 (SSoT, ביטול כפילות). מתועד ב-X9 + gap-audit. 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/tools/cases.py | 64 ++++++------- mcp-server/src/legal_mcp/tools/citations.py | 10 +- mcp-server/src/legal_mcp/tools/documents.py | 93 ++++++++----------- .../src/legal_mcp/tools/internal_decisions.py | 11 +-- .../src/legal_mcp/tools/legal_arguments.py | 29 ++---- .../src/legal_mcp/tools/missing_precedents.py | 10 +- .../src/legal_mcp/tools/precedent_library.py | 12 +-- mcp-server/src/legal_mcp/tools/precedents.py | 27 +++--- .../legal_mcp/tools/training_enrichment.py | 10 +- mcp-server/src/legal_mcp/tools/workflow.py | 61 ++++++------ mcp-server/tests/test_corpus_constraints.py | 7 +- web/app.py | 67 +++++++------ 14 files changed, 168 insertions(+), 240 deletions(-) diff --git a/docs/spec/X9-mcp-tool-contract.md b/docs/spec/X9-mcp-tool-contract.md index a0f7ebe..86e0c1a 100644 --- a/docs/spec/X9-mcp-tool-contract.md +++ b/docs/spec/X9-mcp-tool-contract.md @@ -38,8 +38,8 @@ מקביל ל-[X6 INV-UI3](X6-ui-api-contract.md). **הנדסי.** **מקורות:** Anthropic — *MCP / tool result conventions* (https://modelcontextprotocol.io/) · JSON-RPC 2.0 (result/error envelope) (https://www.jsonrpc.org/specification) · RFC 9457 (Problem Details) | סטטוס: verified -**אכיפה:** wrapper-תשובה משותף בכל הכלים — `tools/envelope.py` (`ok`/`empty`/`err`), SSoT יחיד שמחליף את 5 ה-`_ok`/`_err` המשוכפלים. **GAP-48 בתהליך — פרוסה 1 (משפחת-חיפוש, 2026-06-06):** `search_decisions`/`search_case_documents`/`find_similar_cases`/`search_internal_decisions` מחזירים `{status,data,message}` (status ∈ ok/empty/error — מבחין הצלחה/ריק/שגיאה). צרכני-ה-API ב-`web/app.py` מפרקים דרך `envelope_unwrap` כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי. נותרו ~73 כלים — מיגרציה הדרגתית לפי-משפחה. -**הפרה ידועה:** שאר הכלים עדיין מעורבים (raw payload / `{error}` / `{status,message}` אד-הוק) — ייושרו בפרוסות הבאות של GAP-48. +**אכיפה:** wrapper-תשובה משותף בכל הכלים — `tools/envelope.py` (`ok`/`empty`/`err` → `{status,data,message}`, status ∈ ok/empty/error — מבחין הצלחה/ריק/שגיאה), SSoT יחיד שמחליף את 5 ה-`_ok`/`_err` המשוכפלים. עיקרון: envelope-`status` משקף אם **הקריאה לכלי** הצליחה; תוצאות-עסקיות (completed/failed_gates/...) נשמרות בתוך `data`. צרכני-API ב-`web/app.py` מפרקים דרך `envelope_unwrap` כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי. **GAP-48 בתהליך (2026-06-06):** הומרו — search · precedent_library · citations · internal_decisions · missing_precedents · training_enrichment · precedents · legal_arguments · cases · documents · workflow (~11 משפחות, ~59 כלים). **נותר:** משפחת `drafting` (18 כלים — מסלול הפקת-ההחלטה הקריטי) בפרוסה נפרדת עם שער-טסט-ייצוא. +**הפרה ידועה:** משפחת drafting עדיין מעורבת ({status,message} אד-הוק / מחרוזות) — תיושר בפרוסת drafting. ### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס **כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס** diff --git a/docs/spec/gap-audit.md b/docs/spec/gap-audit.md index c6978af..6943640 100644 --- a/docs/spec/gap-audit.md +++ b/docs/spec/gap-audit.md @@ -202,7 +202,8 @@ - **סטטוס חלקי (פרוסה 2, 2026-06-06):** ✅ **GAP-52** (INV-TOOL3 idempotency) — `case_create`/`precedent_attach`/`document_upload` מחזירים קיים במקום כפילות (בדיקת-מפתח ברמת-אפליקציה; document_upload לפי SHA-256 → מדלג OCR/embed כפול); ✅ **GAP-45** (INV-TOOL4 visibility) — נוסף `extraction_status` שחושף עומק תור-החילוץ (metadata/halacha) + גיל הבקשה הוותיקה. - **סטטוס חלקי (פרוסה 3, 2026-06-06):** ✅ **GAP-51** (set_outcome SSoT, הכרעת-יו"ר "3 תוצאות + הוצאת betterment_levy") — קנוני `rejection/partial_acceptance/full_acceptance` ב-`lessons.VALID_OUTCOMES`; `OUTCOME_LABELS_HE` (עברית-ב-UI SSoT); `canonical_outcome()` ל-legacy; `betterment_levy`→`PRACTICE_AREA_OVERRIDES` (override לפי practice_area); block_writer/set_outcome/drafting/web-ui יושרו; נתונים נורמלו (9 שורות + גיבוי). - **סטטוס חלקי (פרוסה 4, 2026-06-06):** ✅ **GAP-47 (חלק provenance, INV-TOOL4/G9)** — `draft_section` חושף בפלט `document_id`+`page`+`score` לכל קטע ב-`case_documents`/`precedents` (ה-provenance כבר נשלף ב-`search_similar` ונזרק; כעת מוחזר), כך שהכותב יכול לעקוב אל המקור ולצטטו. תוספתי, לא-שובר. **נותר ב-GAP-47:** העברת הנחיות-יו"ר מ-`analysis-and-research.md` ל-DB (`get_chair_directions`) — שינוי-מסלול גדול יותר הנוגע ל-UI של דפנה ולזרימת-האנליסט; לפרוסה נפרדת. -- **סטטוס חלקי (פרוסה 5, 2026-06-06):** 🔄 **GAP-48 (envelope אחיד, INV-TOOL1) — תחילת מיגרציה הדרגתית.** נוצר `tools/envelope.py` כ-SSoT יחיד (`ok`/`empty`/`err` → `{status,data,message}`, status מבחין הצלחה/ריק/שגיאה) המחליף 3 מוסכמות סותרות (raw payload / `{error}` / `{status,message}` אד-הוק) ו-5 עותקי `_ok`/`_err` משוכפלים. **משפחת-החיפוש הראשונה הומרה** (`search_decisions`/`search_case_documents`/`find_similar_cases`/`search_internal_decisions`); `web/app.py` מפרק דרך `envelope_unwrap` לשמירת חוזה-ה-API (X6) ללא-שינוי; טסט `test_search_domain_scope` עודכן לחוזה החדש (5/5 ✅). **החלטה הנדסית:** הדרגתי לפי-משפחה ולא big-bang — מפת-צרכנים (Explore) הראתה ש-server.py הוא pass-through, web-ui מבודד (`/api/*`), ורק 17 כלים נצרכים ישירות מ-app.py — כך הסיכון לסוכנים החיים ממוזער. נותרו ~73 כלים בפרוסות הבאות. נותר ב-FU-14: GAP-48 (שאר המשפחות), GAP-49/50 (מיזוג+rename — שובר), GAP-54 (איחוד קליטת-פסיקה). +- **סטטוס חלקי (פרוסה 5, 2026-06-06):** 🔄 **GAP-48 (envelope אחיד, INV-TOOL1) — תחילת מיגרציה הדרגתית.** נוצר `tools/envelope.py` כ-SSoT יחיד (`ok`/`empty`/`err` → `{status,data,message}`, status מבחין הצלחה/ריק/שגיאה) המחליף 3 מוסכמות סותרות (raw payload / `{error}` / `{status,message}` אד-הוק) ו-5 עותקי `_ok`/`_err` משוכפלים. **משפחת-החיפוש הראשונה הומרה** (`search_decisions`/`search_case_documents`/`find_similar_cases`/`search_internal_decisions`); `web/app.py` מפרק דרך `envelope_unwrap` לשמירת חוזה-ה-API (X6) ללא-שינוי; טסט `test_search_domain_scope` עודכן לחוזה החדש (5/5 ✅). **החלטה הנדסית:** הדרגתי לפי-משפחה ולא big-bang — מפת-צרכנים (Explore) הראתה ש-server.py הוא pass-through, web-ui מבודד (`/api/*`), ורק 17 כלים נצרכים ישירות מ-app.py — כך הסיכון לסוכנים החיים ממוזער. נותרו ~73 כלים בפרוסות הבאות. +- **סטטוס חלקי (פרוסה 6, 2026-06-06):** 🔄 **GAP-48 — מיגרציה רוחבית.** הומרו 10 משפחות נוספות ל-envelope: `precedent_library` (14), `citations` (3), `internal_decisions` (1), `missing_precedents` (4), `training_enrichment` (2), `precedents` (4), `legal_arguments` (2), `cases` (7), `documents` (8), `workflow` (9). בוטלו 5 עותקי `_ok`/`_err` משוכפלים (alias ל-SSoT, G2). עיקרון: envelope-`status` = הצלחת-הקריאה; תוצאה-עסקית (idempotent_existing/noop/...) ב-`data`. צרכני-app.py של cases/workflow/precedents חוּוטו דרך `envelope_unwrap` + בדיקת `status=="error"`→4xx, לשמירת חוזה-ה-API. כל הטסטים עוברים (182/182; `test_corpus_constraints` עודכן לחוזה). **נותר:** משפחת `drafting` (18 כלים — מסלול הפקת-ההחלטה) בפרוסה נפרדת. נותר ב-FU-14: GAP-48 (drafting), GAP-49/50 (מיזוג+rename — שובר), GAP-54 (איחוד קליטת-פסיקה). ### FU-15 — deploy/env/secrets - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1–ENV5 · **effort:** M · **תלויות:** — diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index d507db0..85f762c 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -14,6 +14,7 @@ import httpx from legal_mcp import config from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope logger = logging.getLogger(__name__) @@ -158,7 +159,7 @@ async def case_create( _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) + return ok(_existing) from datetime import date as date_type @@ -257,7 +258,7 @@ async def case_create( # silently producing a case with no remote. case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir) - return json.dumps(case, default=str, ensure_ascii=False, indent=2) + return ok(case) async def case_list(status: str = "", limit: int = 50) -> str: @@ -272,8 +273,8 @@ async def case_list(status: str = "", limit: int = 50) -> str: """ cases = await db.list_cases(status=status or None, limit=limit) if not cases: - return "אין תיקים." - return json.dumps(cases, default=str, ensure_ascii=False, indent=2) + return empty("אין תיקים.") + return ok(cases) async def case_get(case_number: str) -> str: @@ -284,11 +285,11 @@ async def case_get(case_number: str) -> str: """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") docs = await db.list_documents(UUID(case["id"])) case["documents"] = docs - return json.dumps(case, default=str, ensure_ascii=False, indent=2) + return ok(case) async def case_update( @@ -338,7 +339,7 @@ async def case_update( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") fields = {} if status: @@ -395,7 +396,7 @@ async def case_update( except Exception: pass # git not available — non-critical - return json.dumps(updated, default=str, ensure_ascii=False, indent=2) + return ok(updated) async def case_delete(case_number: str, remove_files: bool = False) -> str: @@ -408,28 +409,25 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str: """ case = await db.get_case_by_number(case_number) if not case: - return json.dumps( - {"deleted": False, "reason": f"תיק {case_number} לא נמצא."}, - ensure_ascii=False, - ) + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) - ok = await db.delete_case(case_id) + deleted = await db.delete_case(case_id) result = { - "deleted": ok, + "deleted": deleted, "case_number": case_number, "case_id": str(case_id), "removed_files": False, } - if ok and remove_files: + if deleted and remove_files: case_dir = config.find_case_dir(case_number) if case_dir.exists(): shutil.rmtree(case_dir, ignore_errors=True) result["removed_files"] = True - return json.dumps(result, ensure_ascii=False, indent=2) + return ok(result) async def case_get_final_text(case_number: str, max_chars: int = 0) -> str: @@ -456,27 +454,24 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str: break if final_path is None: - return json.dumps({ - "status": "not_found", - "case_number": case_number, - "expected_path": str(exports_dir / f"{final_stem}.docx"), - "tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"], - "hint": ( - "ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. " - "דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון." - ), - }, ensure_ascii=False, indent=2) + return err( + "ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. " + "דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון.", + data={ + "case_number": case_number, + "expected_path": str(exports_dir / f"{final_stem}.docx"), + "tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"], + }, + ) try: text, page_count, _ = await extractor.extract_text(str(final_path)) except Exception as e: logger.exception("case_get_final_text: extraction failed for %s", case_number) - return json.dumps({ - "status": "error", - "case_number": case_number, - "file_path": str(final_path), - "error": str(e), - }, ensure_ascii=False, indent=2) + return err( + f"חילוץ הטקסט נכשל: {e}", + data={"case_number": case_number, "file_path": str(final_path)}, + ) text = text or "" truncated = False @@ -484,12 +479,11 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str: text = text[:max_chars] truncated = True - return json.dumps({ - "status": "ok", + return ok({ "case_number": case_number, "file_path": str(final_path), "text_length": len(text), "page_count": page_count, "truncated": truncated, "text": text, - }, ensure_ascii=False, indent=2) + }) diff --git a/mcp-server/src/legal_mcp/tools/citations.py b/mcp-server/src/legal_mcp/tools/citations.py index 6273430..c8bf01c 100644 --- a/mcp-server/src/legal_mcp/tools/citations.py +++ b/mcp-server/src/legal_mcp/tools/citations.py @@ -23,18 +23,10 @@ missing decision so that newer rows now link to it). from __future__ import annotations -import json from uuid import UUID from legal_mcp.services import citation_extractor - - -def _ok(payload) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2, default=str) - - -def _err(msg: str) -> str: - return json.dumps({"error": msg}, ensure_ascii=False) +from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope async def extract_internal_citations( diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 54d6b06..39aafac 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -10,6 +10,7 @@ from uuid import UUID from legal_mcp import config from legal_mcp.services import audit, db, git_sync, processor +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope async def document_upload( @@ -28,11 +29,11 @@ async def document_upload( """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") source = Path(file_path) if not source.exists(): - return f"קובץ לא נמצא: {file_path}" + return err(f"קובץ לא נמצא: {file_path}") case_id = UUID(case["id"]) if not title: @@ -44,12 +45,10 @@ async def document_upload( 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) — מוחזר הקיים, ללא עיבוד מחדש.", + return ok({ "document": existing_doc, "idempotent_existing": True, - }, ensure_ascii=False, indent=2, default=str) + }, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.") # Copy file to case directory case_dir = config.find_case_dir(case_number) / "documents" / "originals" @@ -106,10 +105,10 @@ async def document_upload( "document_upload", case_id=case_id, document_id=UUID(doc["id"]), details={"title": title, "doc_type": actual_doc_type}, ) - return json.dumps({ + return ok({ "document": doc, "processing": result, - }, default=str, ensure_ascii=False, indent=2) + }) async def document_upload_training( @@ -139,7 +138,7 @@ async def document_upload_training( source = Path(file_path) if not source.exists(): - return f"קובץ לא נמצא: {file_path}" + return err(f"קובץ לא נמצא: {file_path}") if not title: title = source.stem @@ -214,13 +213,13 @@ async def document_upload_training( ] await db.store_chunks(doc_id, None, chunk_dicts) - return json.dumps({ + return ok({ "corpus_id": str(corpus_id), "title": title, "pages": page_count, "text_length": len(text), "chunks": len(chunks) if chunks else 0, - }, default=str, ensure_ascii=False, indent=2) + }) async def document_get_text(case_number: str, doc_title: str = "") -> str: @@ -232,16 +231,16 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str: """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") docs = await db.list_documents(UUID(case["id"])) if not docs: - return f"אין מסמכים בתיק {case_number}." + return empty(f"אין מסמכים בתיק {case_number}.") if doc_title: docs = [d for d in docs if doc_title.lower() in d["title"].lower()] if not docs: - return f"מסמך '{doc_title}' לא נמצא בתיק." + return err(f"מסמך '{doc_title}' לא נמצא בתיק.") results = [] for doc in docs: @@ -252,7 +251,7 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str: "text": text[:10000] if text else "(ללא טקסט)", }) - return json.dumps(results, ensure_ascii=False, indent=2) + return ok(results) async def document_list(case_number: str) -> str: @@ -263,13 +262,13 @@ async def document_list(case_number: str) -> str: """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") docs = await db.list_documents(UUID(case["id"])) if not docs: - return f"אין מסמכים בתיק {case_number}." + return empty(f"אין מסמכים בתיק {case_number}.") - return json.dumps(docs, default=str, ensure_ascii=False, indent=2) + return ok(docs) async def extract_references( @@ -286,12 +285,12 @@ async def extract_references( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) docs = await db.list_documents(case_id) if not docs: - return f"אין מסמכים בתיק {case_number}." + return empty(f"אין מסמכים בתיק {case_number}.") if doc_title: docs = [d for d in docs if doc_title.lower() in d["title"].lower()] @@ -313,7 +312,7 @@ async def extract_references( "legislation": refs["legislation"], }) - return json.dumps(results, default=str, ensure_ascii=False, indent=2) + return ok(results) async def extract_claims( @@ -332,12 +331,12 @@ async def extract_claims( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) docs = await db.list_documents(case_id) if not docs: - return f"אין מסמכים בתיק {case_number}." + return empty(f"אין מסמכים בתיק {case_number}.") # Filter to claims documents (appeal, response) or specific doc if doc_title: @@ -346,7 +345,7 @@ async def extract_claims( docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")] if not docs: - return "לא נמצאו כתבי טענות בתיק." + return empty("לא נמצאו כתבי טענות בתיק.") results = [] for doc in docs: @@ -367,7 +366,7 @@ async def extract_claims( "extract_claims", case_id=case_id, details={"docs_processed": len(docs), "results": len(results)}, ) - return json.dumps(results, default=str, ensure_ascii=False, indent=2) + return ok(results) async def get_claims(case_number: str, party_role: str = "") -> str: @@ -379,7 +378,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str: """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") claims = await db.get_claims( UUID(case["id"]), @@ -387,7 +386,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str: ) if not claims: - return f"אין טענות בתיק {case_number}." + return empty(f"אין טענות בתיק {case_number}.") # Format for display role_hebrew = { @@ -405,7 +404,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str: "source": c.get("source_document", ""), }) - return json.dumps(formatted, default=str, ensure_ascii=False, indent=2) + return ok(formatted) # Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES. @@ -440,37 +439,26 @@ async def document_update( """ case = await db.get_case_by_number(case_number) if not case: - return json.dumps({"status": "error", - "message": f"תיק {case_number} לא נמצא."}, - ensure_ascii=False, indent=2) + return err(f"תיק {case_number} לא נמצא.") try: doc_uuid = UUID(doc_id) except ValueError: - return json.dumps({"status": "error", - "message": f"doc_id לא תקין: {doc_id}"}, - ensure_ascii=False, indent=2) + return err(f"doc_id לא תקין: {doc_id}") doc = await db.get_document(doc_uuid) if not doc: - return json.dumps({"status": "error", - "message": f"מסמך {doc_id} לא נמצא."}, - ensure_ascii=False, indent=2) + return err(f"מסמך {doc_id} לא נמצא.") if doc.get("case_id") != case["id"]: - return json.dumps({"status": "error", - "message": f"מסמך {doc_id} לא שייך לתיק {case_number}."}, - ensure_ascii=False, indent=2) + return err(f"מסמך {doc_id} לא שייך לתיק {case_number}.") updates: dict = {} if doc_type: if doc_type not in ALLOWED_DOC_TYPES: - return json.dumps({ - "status": "error", - "message": f"doc_type לא תקין: {doc_type}", - "allowed": sorted(ALLOWED_DOC_TYPES), - }, ensure_ascii=False, indent=2) + return err(f"doc_type לא תקין: {doc_type}", + data={"allowed": sorted(ALLOWED_DOC_TYPES)}) updates["doc_type"] = doc_type # appraiser_side is optional. The MCP tool can't distinguish "skip" from @@ -478,11 +466,8 @@ async def document_update( # To clear, the operator must edit metadata directly (rare). if appraiser_side: if appraiser_side not in ALLOWED_APPRAISER_SIDES: - return json.dumps({ - "status": "error", - "message": f"appraiser_side לא תקין: {appraiser_side}", - "allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s), - }, ensure_ascii=False, indent=2) + return err(f"appraiser_side לא תקין: {appraiser_side}", + data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)}) metadata = doc.get("metadata") or {} if isinstance(metadata, str): metadata = json.loads(metadata) @@ -490,14 +475,12 @@ async def document_update( updates["metadata"] = metadata if not updates: - return json.dumps({"status": "noop", "message": "אין שינוי לבצע."}, - ensure_ascii=False, indent=2) + return ok({"noop": True}, message="אין שינוי לבצע.") await db.update_document(doc_uuid, **updates) fresh = await db.get_document(doc_uuid) - return json.dumps({ - "status": "completed", + return ok({ "doc_id": doc_id, "doc_type": fresh.get("doc_type"), "metadata": fresh.get("metadata"), - }, default=str, ensure_ascii=False, indent=2) + }) diff --git a/mcp-server/src/legal_mcp/tools/internal_decisions.py b/mcp-server/src/legal_mcp/tools/internal_decisions.py index fb6b5fa..b7be940 100644 --- a/mcp-server/src/legal_mcp/tools/internal_decisions.py +++ b/mcp-server/src/legal_mcp/tools/internal_decisions.py @@ -14,9 +14,8 @@ decisions and enforces the required metadata at the tool boundary. from __future__ import annotations -import json - from legal_mcp.services import internal_decisions as int_svc +from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope # Valid Hebrew district names (matches _COURT_TO_DISTRICT in service) VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"} @@ -26,14 +25,6 @@ VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'} -def _ok(payload) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2, default=str) - - -def _err(msg: str) -> str: - return json.dumps({"error": msg}, ensure_ascii=False) - - async def internal_decision_upload( file_path: str, case_number: str, diff --git a/mcp-server/src/legal_mcp/tools/legal_arguments.py b/mcp-server/src/legal_mcp/tools/legal_arguments.py index 3fa1298..656bedb 100644 --- a/mcp-server/src/legal_mcp/tools/legal_arguments.py +++ b/mcp-server/src/legal_mcp/tools/legal_arguments.py @@ -2,10 +2,10 @@ from __future__ import annotations -import json from uuid import UUID from legal_mcp.services import argument_aggregator, db +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope async def aggregate_claims_to_arguments( @@ -20,17 +20,14 @@ async def aggregate_claims_to_arguments( """ case = await db.get_case_by_number(case_number) if not case: - return json.dumps( - {"status": "error", "message": f"תיק {case_number} לא נמצא."}, - ensure_ascii=False, indent=2, - ) + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) result = await argument_aggregator.aggregate_claims_to_arguments( case_id, force=force, ) result["case_number"] = case_number - return json.dumps(result, ensure_ascii=False, indent=2, default=str) + return ok(result) async def get_legal_arguments( @@ -46,21 +43,16 @@ async def get_legal_arguments( """ case = await db.get_case_by_number(case_number) if not case: - return json.dumps( - {"status": "error", "message": f"תיק {case_number} לא נמצא."}, - ensure_ascii=False, indent=2, - ) + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) args = await argument_aggregator.get_legal_arguments(case_id, party=party) if not args: - return json.dumps({ - "status": "empty", - "case_number": case_number, - "message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.", - "arguments": [], - }, ensure_ascii=False, indent=2) + return empty( + "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.", + data={"case_number": case_number, "arguments": []}, + ) # Group by party for nicer display. party_he = { @@ -75,9 +67,8 @@ async def get_legal_arguments( label = party_he.get(a["party"], a["party"]) by_party.setdefault(label, []).append(a) - return json.dumps({ - "status": "ok", + return ok({ "case_number": case_number, "total": len(args), "by_party": by_party, - }, ensure_ascii=False, indent=2, default=str) + }) diff --git a/mcp-server/src/legal_mcp/tools/missing_precedents.py b/mcp-server/src/legal_mcp/tools/missing_precedents.py index 03d02d4..3565278 100644 --- a/mcp-server/src/legal_mcp/tools/missing_precedents.py +++ b/mcp-server/src/legal_mcp/tools/missing_precedents.py @@ -18,18 +18,10 @@ Three tools: from __future__ import annotations -import json from uuid import UUID from legal_mcp.services import db - - -def _ok(payload) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2, default=str) - - -def _err(msg: str) -> str: - return json.dumps({"error": msg}, ensure_ascii=False) +from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope async def _resolve_case_id(case_number: str) -> UUID | None: diff --git a/mcp-server/src/legal_mcp/tools/precedent_library.py b/mcp-server/src/legal_mcp/tools/precedent_library.py index 640d17c..2c1b719 100644 --- a/mcp-server/src/legal_mcp/tools/precedent_library.py +++ b/mcp-server/src/legal_mcp/tools/precedent_library.py @@ -17,19 +17,11 @@ the chair approves them — per project review policy. from __future__ import annotations -import json import time from uuid import UUID from legal_mcp.services import db, precedent_library, telemetry - - -def _ok(payload) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2, default=str) - - -def _err(msg: str) -> str: - return json.dumps({"error": msg}, ensure_ascii=False) +from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope async def precedent_library_upload( @@ -293,7 +285,7 @@ async def search_precedent_library( Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}. """ if not query or len(query.strip()) < 2: - return json.dumps([], ensure_ascii=False) + return empty("שאילתה קצרה מדי (פחות מ-2 תווים).") q = query.strip() t0 = time.perf_counter() results = await precedent_library.search_library( diff --git a/mcp-server/src/legal_mcp/tools/precedents.py b/mcp-server/src/legal_mcp/tools/precedents.py index a17406c..68e2428 100644 --- a/mcp-server/src/legal_mcp/tools/precedents.py +++ b/mcp-server/src/legal_mcp/tools/precedents.py @@ -7,11 +7,10 @@ free-text citations the chair attaches during the compose phase. from __future__ import annotations -import json -from pathlib import Path from uuid import UUID from legal_mcp.services import db +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope async def precedent_attach( @@ -34,14 +33,14 @@ async def precedent_attach( """ case = await db.get_case_by_number(case_number) if not case: - return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False) + return err(f"תיק {case_number} לא נמצא.") pdf_uuid: UUID | None = None if pdf_document_id: try: pdf_uuid = UUID(pdf_document_id) except ValueError: - return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False) + return err("pdf_document_id לא תקין") # 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. @@ -49,7 +48,7 @@ async def precedent_attach( 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) + return ok(_p) row = await db.create_case_precedent( case_id=UUID(case["id"]), @@ -60,17 +59,17 @@ async def precedent_attach( pdf_document_id=pdf_uuid, practice_area=case.get("practice_area"), ) - return json.dumps(row, ensure_ascii=False, indent=2, default=str) + return ok(row) async def precedent_list(case_number: str) -> str: """רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה.""" case = await db.get_case_by_number(case_number) if not case: - return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False) + return err(f"תיק {case_number} לא נמצא.") rows = await db.list_case_precedents(UUID(case["id"])) - return json.dumps(rows, ensure_ascii=False, indent=2, default=str) + return ok(rows) async def precedent_remove(precedent_id: str) -> str: @@ -78,12 +77,10 @@ async def precedent_remove(precedent_id: str) -> str: try: pid = UUID(precedent_id) except ValueError: - return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False) + return err("precedent_id לא תקין") - ok = await db.delete_case_precedent(pid) - return json.dumps( - {"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False, - ) + deleted = await db.delete_case_precedent(pid) + return ok({"deleted": deleted, "precedent_id": precedent_id}) async def precedent_search_library( @@ -97,7 +94,7 @@ async def precedent_search_library( limit: מספר תוצאות מקסימלי """ if not query or len(query.strip()) < 2: - return json.dumps([], ensure_ascii=False) + return empty("שאילתה קצרה מדי (פחות מ-2 תווים).") rows = await db.search_precedent_library(query.strip(), practice_area, limit) - return json.dumps(rows, ensure_ascii=False, indent=2, default=str) + return ok(rows) diff --git a/mcp-server/src/legal_mcp/tools/training_enrichment.py b/mcp-server/src/legal_mcp/tools/training_enrichment.py index 016a625..1581d65 100644 --- a/mcp-server/src/legal_mcp/tools/training_enrichment.py +++ b/mcp-server/src/legal_mcp/tools/training_enrichment.py @@ -15,18 +15,10 @@ CLI is available, and the row gets enriched. from __future__ import annotations -import json from uuid import UUID from legal_mcp.services import db, style_metadata_extractor - - -def _ok(payload) -> str: - return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str) - - -def _err(msg: str) -> str: - return json.dumps({"ok": False, "error": msg}, ensure_ascii=False) +from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str: diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 828cddb..8e3a2ef 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import logging from uuid import UUID @@ -12,6 +11,7 @@ from legal_mcp.services.lessons import ( VALID_OUTCOMES, canonical_outcome, ) +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ async def workflow_status(case_number: str) -> str: """ case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) docs = await db.list_documents(case_id) @@ -69,7 +69,7 @@ async def workflow_status(case_number: str) -> str: "next_steps": _suggest_next_steps(case, docs, has_draft), } - return json.dumps(status, ensure_ascii=False, indent=2) + return ok(status) def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]: @@ -114,12 +114,12 @@ async def get_metrics(case_number: str = "") -> str: if case_number: case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") result = await metrics.get_case_metrics(UUID(case["id"])) else: result = await metrics.get_dashboard() - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) async def processing_status() -> str: @@ -135,14 +135,14 @@ async def processing_status() -> str: corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus") pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns") - return json.dumps({ + return ok({ "cases": case_count, "documents": doc_count, "pending_processing": pending_count, "chunks": chunk_count, "style_corpus_entries": corpus_count, "style_patterns": pattern_count, - }, ensure_ascii=False, indent=2) + }) # ── Outcome & Brainstorming ─────────────────────────────────────── @@ -164,12 +164,12 @@ async def set_outcome( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") # GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical. outcome = canonical_outcome(outcome) if outcome not in VALID_OUTCOMES: - return f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}" + return err(f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}") case_id = UUID(case["id"]) @@ -211,7 +211,7 @@ async def set_outcome( result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה." result["next_step"] = "draft" - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) async def brainstorm_directions( @@ -226,14 +226,14 @@ async def brainstorm_directions( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) # Get existing decision for outcome decision = await db.get_decision_by_case(case_id) if not decision: - return "לא הוזנה תוצאה לתיק. הפעל set_outcome קודם." + return err("לא הוזנה תוצאה לתיק. הפעל set_outcome קודם.") outcome = decision.get("outcome", "") reasoning = decision.get("outcome_reasoning", "") @@ -246,7 +246,7 @@ async def brainstorm_directions( direction_doc={"brainstorm": directions, "approved": False}, ) - return json.dumps(directions, default=str, ensure_ascii=False, indent=2) + return ok(directions) async def approve_direction( @@ -265,18 +265,18 @@ async def approve_direction( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) decision = await db.get_decision_by_case(case_id) if not decision: - return "לא הוזנה תוצאה לתיק." + return err("לא הוזנה תוצאה לתיק.") direction_data = decision.get("direction_doc") or {} brainstorm_result = direction_data.get("brainstorm", {}) if not brainstorm_result.get("directions"): - return "לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם." + return err("לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם.") direction_doc = brainstorm.build_direction_doc( outcome=decision.get("outcome", ""), @@ -288,11 +288,8 @@ async def approve_direction( await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc) - return json.dumps({ - "status": "approved", - "message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.", - "direction": direction_doc, - }, default=str, ensure_ascii=False, indent=2) + return ok({"direction": direction_doc}, + message="כיוון אושר. ניתן להתחיל כתיבת טיוטה.") async def ingest_final_version( @@ -311,7 +308,7 @@ async def ingest_final_version( case = await db.get_case_by_number(case_number) if not case: - return f"תיק {case_number} לא נמצא." + return err(f"תיק {case_number} לא נמצא.") case_id = UUID(case["id"]) @@ -321,12 +318,12 @@ async def ingest_final_version( final_text, _, _ = await extractor.extract_text(file_path) if not final_text: - return "לא סופק טקסט — יש לספק file_path או final_text." + return err("לא סופק טקסט — יש לספק file_path או final_text.") try: result = await learning_loop.process_final_version(case_id, final_text) except ValueError as e: - return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) + return err(str(e)) # Auto-ingest into internal committee decisions corpus (best-effort). try: @@ -346,7 +343,7 @@ async def ingest_final_version( logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e) result["internal_corpus_ingested"] = False - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) # ── Chair feedback tools ────────────────────────────────────────── @@ -376,7 +373,7 @@ async def record_chair_feedback( "factual_error", "style", "other", ] if category not in valid_categories: - return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}" + return err(f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}") feedback_id = await db.record_chair_feedback( case_id=case_id, @@ -386,15 +383,13 @@ async def record_chair_feedback( lesson_extracted=lesson_extracted, ) - return json.dumps({ - "status": "ok", + return ok({ "feedback_id": str(feedback_id), - "message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.", "next_steps": [ "כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback", "כדי לסמן כמטופל: resolve_chair_feedback", ], - }, ensure_ascii=False, indent=2) + }, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.") async def list_chair_feedback( @@ -425,7 +420,7 @@ async def list_chair_feedback( ) if not feedbacks: - return "אין הערות שמתאימות לסינון." + return empty("אין הערות שמתאימות לסינון.") items = [] for fb in feedbacks: @@ -440,7 +435,7 @@ async def list_chair_feedback( "date": fb["created_at"].isoformat() if fb.get("created_at") else None, }) - return json.dumps({ + return ok({ "total": len(items), "feedbacks": items, - }, ensure_ascii=False, indent=2, default=str) + }) diff --git a/mcp-server/tests/test_corpus_constraints.py b/mcp-server/tests/test_corpus_constraints.py index 5479d7e..fd99c56 100644 --- a/mcp-server/tests/test_corpus_constraints.py +++ b/mcp-server/tests/test_corpus_constraints.py @@ -234,14 +234,15 @@ def test_mcp_precedent_upload_rejects_arar_citation() -> None: "ARAR 8126-25 ב. קרן-נכסים", ): result = loop.run_until_complete(call(citation)) - assert "error" in result, ( + # GAP-48: tools return the {status,data,message} envelope. + assert result.get("status") == "error", ( f"expected guard to reject {citation!r}, got {result!r}" ) # The error message should mention internal_decision_upload so # the caller knows the alternative path. - assert "internal_decision_upload" in result["error"], ( + assert "internal_decision_upload" in result["message"], ( f"error message should redirect to internal_decision_upload, " - f"got {result['error']!r}" + f"got {result['message']!r}" ) finally: loop.close() diff --git a/web/app.py b/web/app.py index 305a566..9831799 100644 --- a/web/app.py +++ b/web/app.py @@ -1896,7 +1896,9 @@ async def api_case_create(req: CaseCreateRequest): appeal_subtype=req.appeal_subtype, proceeding_type=req.proceeding_type, ) - parsed = json.loads(result) + # GAP-48: case_create now returns the {status,data,message} envelope; unwrap + # to the case object so the existing gitea/appeal_subtype/paperclip wiring works. + parsed = envelope_unwrap(json.loads(result)) # Auto-create Paperclip project for the new case. case_create may have # auto-derived appeal_subtype from the case-number prefix; prefer the @@ -1998,10 +2000,10 @@ async def api_case_git_status(case_number: str): async def api_case_get(case_number: str): """Get full case details including documents.""" result = await cases_tools.case_get(case_number) - try: - return json.loads(result) - except json.JSONDecodeError: - raise HTTPException(404, result) + parsed = json.loads(result) + if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48 + raise HTTPException(404, parsed.get("message") or result) + return envelope_unwrap(parsed) @app.put("/api/cases/{case_number}") @@ -2030,10 +2032,10 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest, background_t ) except ValueError as exc: raise HTTPException(422, str(exc)) - try: - parsed = json.loads(result) - except json.JSONDecodeError: - raise HTTPException(404, result) + parsed = json.loads(result) + if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48 + raise HTTPException(404, parsed.get("message") or result) + parsed = envelope_unwrap(parsed) # Paperclip sync: update project name when title changes (fire-and-forget). old_title = (existing or {}).get("title", "") @@ -2075,9 +2077,9 @@ async def api_case_delete(case_number: str, remove_files: bool = False): FK ON DELETE CASCADE; audit_log rows nullify their case_id. Pass `remove_files=true` to also rm -rf the on-disk case directory.""" result = await cases_tools.case_delete(case_number, remove_files) - data = json.loads(result) + data = envelope_unwrap(json.loads(result)) # GAP-48 if not data.get("deleted"): - raise HTTPException(404, data.get("reason", f"תיק {case_number} לא נמצא")) + raise HTTPException(404, data.get("message") or data.get("reason") or f"תיק {case_number} לא נמצא") return data @@ -2085,10 +2087,10 @@ async def api_case_delete(case_number: str, remove_files: bool = False): async def api_case_status(case_number: str): """Get full workflow status for a case.""" result = await workflow_tools.workflow_status(case_number) - try: - return json.loads(result) - except json.JSONDecodeError: - raise HTTPException(404, result) + parsed = json.loads(result) + if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48 + raise HTTPException(404, parsed.get("message") or result) + return envelope_unwrap(parsed) @app.get("/api/search") @@ -2176,7 +2178,7 @@ async def api_case_template(case_number: str): async def api_processing_status(): """Get overall processing status.""" result = await workflow_tools.processing_status() - return json.loads(result) + return envelope_unwrap(json.loads(result)) # GAP-48 @app.get("/api/system/diagnostics") @@ -2965,10 +2967,10 @@ async def api_precedent_attach(case_number: str, req: PrecedentCreateRequest): chair_note=req.chair_note, pdf_document_id=req.pdf_document_id, ) - data = json.loads(result) - if data.get("error"): - raise HTTPException(404, data["error"]) - return data + parsed = json.loads(result) # GAP-48 + if parsed.get("status") == "error": + raise HTTPException(404, parsed.get("message") or "") + return envelope_unwrap(parsed) @app.post("/api/cases/{case_number}/precedents/upload-pdf") @@ -3022,10 +3024,10 @@ async def api_precedent_upload_pdf( async def api_precedent_list(case_number: str): """List all precedents attached to a case, grouped client-side by section_id.""" result = await precedents_tools.precedent_list(case_number) - data = json.loads(result) - if isinstance(data, dict) and data.get("error"): - raise HTTPException(404, data["error"]) - return data + parsed = json.loads(result) # GAP-48 + if isinstance(parsed, dict) and parsed.get("status") == "error": + raise HTTPException(404, parsed.get("message") or "") + return envelope_unwrap(parsed) @app.delete("/api/precedents/{precedent_id}") @@ -3034,9 +3036,10 @@ async def api_precedent_delete(precedent_id: str): in the documents table — orphaned references nullify via FK ON DELETE SET NULL — so we keep the audit trail of the file.""" result = await precedents_tools.precedent_remove(precedent_id) - data = json.loads(result) - if data.get("error"): - raise HTTPException(400, data["error"]) + parsed = json.loads(result) # GAP-48 + if parsed.get("status") == "error": + raise HTTPException(400, parsed.get("message") or "") + data = envelope_unwrap(parsed) if not data.get("deleted"): raise HTTPException(404, "לא נמצא") return data @@ -3046,7 +3049,10 @@ async def api_precedent_delete(precedent_id: str): async def api_precedent_search(q: str, practice_area: str = "", limit: int = 10): """Cross-case library typeahead. Returns one row per distinct citation.""" result = await precedents_tools.precedent_search_library(q, practice_area, limit) - return json.loads(result) + parsed = json.loads(result) # GAP-48: typeahead expects an array + if isinstance(parsed, dict) and parsed.get("status") == "error": + raise HTTPException(400, parsed.get("message") or "") + return parsed.get("data") or [] # ── Exports API — drafts, versions, download, upload, mark-final ── @@ -3443,9 +3449,10 @@ async def api_start_workflow(case_number: str): """ # 1. Verify case exists and status is appropriate case_raw = await cases_tools.case_get(case_number) - case_data = json.loads(case_raw) - if "error" in case_data: + case_env = json.loads(case_raw) # GAP-48 + if isinstance(case_env, dict) and case_env.get("status") == "error": raise HTTPException(404, f"תיק {case_number} לא נמצא") + case_data = envelope_unwrap(case_env) status = case_data.get("status", "") allowed = {"new", "documents_ready"}