Merge pull request 'feat(mcp): FU-14 GAP-48 פרוסה 2 — envelope אחיד ל-11 משפחות-כלים' (#77) from fix/fu14-gap48-envelope-rest into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m5s

This commit was merged in pull request #77.
This commit is contained in:
2026-06-06 17:42:00 +00:00
14 changed files with 168 additions and 240 deletions

View File

@@ -38,8 +38,8 @@
מקביל ל-[X6 INV-UI3](X6-ui-api-contract.md). **הנדסי.** מקביל ל-[X6 INV-UI3](X6-ui-api-contract.md). **הנדסי.**
**מקורות:** Anthropic — *MCP / tool result conventions* (https://modelcontextprotocol.io/) · **מקורות:** 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 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 כלים — מיגרציה הדרגתית לפי-משפחה. **אכיפה:** 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 כלים — מסלול הפקת-ההחלטה הקריטי) בפרוסה נפרדת עם שער-טסט-ייצוא.
**הפרה ידועה:** שאר הכלים עדיין מעורבים (raw payload / `{error}` / `{status,message}` אד-הוק) — ייושרו בפרוסות הבאות של GAP-48. **הפרה ידועה:** משפחת drafting עדיין מעורבת ({status,message} אד-הוק / מחרוזות) — תיושר בפרוסת drafting.
### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס ### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס
**כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס** **כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס**

View File

@@ -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) + גיל הבקשה הוותיקה. - **סטטוס חלקי (פרוסה 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 שורות + גיבוי). - **סטטוס חלקי (פרוסה 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 של דפנה ולזרימת-האנליסט; לפרוסה נפרדת. - **סטטוס חלקי (פרוסה 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 ### FU-15 — deploy/env/secrets
- **מכסה:** GAP-55..62 · **invariants:** INV-ENV1ENV5 · **effort:** M · **תלויות:** - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1ENV5 · **effort:** M · **תלויות:**

View File

@@ -14,6 +14,7 @@ import httpx
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa 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__) logger = logging.getLogger(__name__)
@@ -158,7 +159,7 @@ async def case_create(
_existing = await db.get_case_by_number(case_number) _existing = await db.get_case_by_number(case_number)
if _existing: if _existing:
_existing["idempotent_existing"] = True _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 from datetime import date as date_type
@@ -257,7 +258,7 @@ async def case_create(
# silently producing a case with no remote. # silently producing a case with no remote.
case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir) 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: 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) cases = await db.list_cases(status=status or None, limit=limit)
if not cases: if not cases:
return "אין תיקים." return empty("אין תיקים.")
return json.dumps(cases, default=str, ensure_ascii=False, indent=2) return ok(cases)
async def case_get(case_number: str) -> str: 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
case["documents"] = docs case["documents"] = docs
return json.dumps(case, default=str, ensure_ascii=False, indent=2) return ok(case)
async def case_update( async def case_update(
@@ -338,7 +339,7 @@ async def case_update(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
fields = {} fields = {}
if status: if status:
@@ -395,7 +396,7 @@ async def case_update(
except Exception: except Exception:
pass # git not available — non-critical 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: 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
ensure_ascii=False,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
ok = await db.delete_case(case_id) deleted = await db.delete_case(case_id)
result = { result = {
"deleted": ok, "deleted": deleted,
"case_number": case_number, "case_number": case_number,
"case_id": str(case_id), "case_id": str(case_id),
"removed_files": False, "removed_files": False,
} }
if ok and remove_files: if deleted and remove_files:
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
shutil.rmtree(case_dir, ignore_errors=True) shutil.rmtree(case_dir, ignore_errors=True)
result["removed_files"] = 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: 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 break
if final_path is None: if final_path is None:
return json.dumps({ return err(
"status": "not_found", "ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
"case_number": case_number, "דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון.",
"expected_path": str(exports_dir / f"{final_stem}.docx"), data={
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"], "case_number": case_number,
"hint": ( "expected_path": str(exports_dir / f"{final_stem}.docx"),
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. " "tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון." },
), )
}, ensure_ascii=False, indent=2)
try: try:
text, page_count, _ = await extractor.extract_text(str(final_path)) text, page_count, _ = await extractor.extract_text(str(final_path))
except Exception as e: except Exception as e:
logger.exception("case_get_final_text: extraction failed for %s", case_number) logger.exception("case_get_final_text: extraction failed for %s", case_number)
return json.dumps({ return err(
"status": "error", f"חילוץ הטקסט נכשל: {e}",
"case_number": case_number, data={"case_number": case_number, "file_path": str(final_path)},
"file_path": str(final_path), )
"error": str(e),
}, ensure_ascii=False, indent=2)
text = text or "" text = text or ""
truncated = False truncated = False
@@ -484,12 +479,11 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
text = text[:max_chars] text = text[:max_chars]
truncated = True truncated = True
return json.dumps({ return ok({
"status": "ok",
"case_number": case_number, "case_number": case_number,
"file_path": str(final_path), "file_path": str(final_path),
"text_length": len(text), "text_length": len(text),
"page_count": page_count, "page_count": page_count,
"truncated": truncated, "truncated": truncated,
"text": text, "text": text,
}, ensure_ascii=False, indent=2) })

View File

@@ -23,18 +23,10 @@ missing decision so that newer rows now link to it).
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import citation_extractor from legal_mcp.services import citation_extractor
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
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 extract_internal_citations( async def extract_internal_citations(

View File

@@ -10,6 +10,7 @@ from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, git_sync, processor 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( async def document_upload(
@@ -28,11 +29,11 @@ async def document_upload(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
source = Path(file_path) source = Path(file_path)
if not source.exists(): if not source.exists():
return f"קובץ לא נמצא: {file_path}" return err(f"קובץ לא נמצא: {file_path}")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
if not title: if not title:
@@ -44,12 +45,10 @@ async def document_upload(
content_hash = hashlib.sha256(source.read_bytes()).hexdigest() content_hash = hashlib.sha256(source.read_bytes()).hexdigest()
existing_doc = await db.get_document_by_hash(case_id, content_hash) existing_doc = await db.get_document_by_hash(case_id, content_hash)
if existing_doc: if existing_doc:
return json.dumps({ return ok({
"status": "exists",
"message": f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.",
"document": existing_doc, "document": existing_doc,
"idempotent_existing": True, "idempotent_existing": True,
}, ensure_ascii=False, indent=2, default=str) }, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
# Copy file to case directory # Copy file to case directory
case_dir = config.find_case_dir(case_number) / "documents" / "originals" 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"]), "document_upload", case_id=case_id, document_id=UUID(doc["id"]),
details={"title": title, "doc_type": actual_doc_type}, details={"title": title, "doc_type": actual_doc_type},
) )
return json.dumps({ return ok({
"document": doc, "document": doc,
"processing": result, "processing": result,
}, default=str, ensure_ascii=False, indent=2) })
async def document_upload_training( async def document_upload_training(
@@ -139,7 +138,7 @@ async def document_upload_training(
source = Path(file_path) source = Path(file_path)
if not source.exists(): if not source.exists():
return f"קובץ לא נמצא: {file_path}" return err(f"קובץ לא נמצא: {file_path}")
if not title: if not title:
title = source.stem title = source.stem
@@ -214,13 +213,13 @@ async def document_upload_training(
] ]
await db.store_chunks(doc_id, None, chunk_dicts) await db.store_chunks(doc_id, None, chunk_dicts)
return json.dumps({ return ok({
"corpus_id": str(corpus_id), "corpus_id": str(corpus_id),
"title": title, "title": title,
"pages": page_count, "pages": page_count,
"text_length": len(text), "text_length": len(text),
"chunks": len(chunks) if chunks else 0, "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: 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
if doc_title: if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()] docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
if not docs: if not docs:
return f"מסמך '{doc_title}' לא נמצא בתיק." return err(f"מסמך '{doc_title}' לא נמצא בתיק.")
results = [] results = []
for doc in docs: 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 "(ללא טקסט)", "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: 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
if not docs: 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( async def extract_references(
@@ -286,12 +285,12 @@ async def extract_references(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
if doc_title: if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()] 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"], "legislation": refs["legislation"],
}) })
return json.dumps(results, default=str, ensure_ascii=False, indent=2) return ok(results)
async def extract_claims( async def extract_claims(
@@ -332,12 +331,12 @@ async def extract_claims(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
# Filter to claims documents (appeal, response) or specific doc # Filter to claims documents (appeal, response) or specific doc
if doc_title: 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")] docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
if not docs: if not docs:
return "לא נמצאו כתבי טענות בתיק." return empty("לא נמצאו כתבי טענות בתיק.")
results = [] results = []
for doc in docs: for doc in docs:
@@ -367,7 +366,7 @@ async def extract_claims(
"extract_claims", case_id=case_id, "extract_claims", case_id=case_id,
details={"docs_processed": len(docs), "results": len(results)}, 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: 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
claims = await db.get_claims( claims = await db.get_claims(
UUID(case["id"]), UUID(case["id"]),
@@ -387,7 +386,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
) )
if not claims: if not claims:
return f"אין טענות בתיק {case_number}." return empty(f"אין טענות בתיק {case_number}.")
# Format for display # Format for display
role_hebrew = { role_hebrew = {
@@ -405,7 +404,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
"source": c.get("source_document", ""), "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. # 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
try: try:
doc_uuid = UUID(doc_id) doc_uuid = UUID(doc_id)
except ValueError: except ValueError:
return json.dumps({"status": "error", return err(f"doc_id לא תקין: {doc_id}")
"message": f"doc_id לא תקין: {doc_id}"},
ensure_ascii=False, indent=2)
doc = await db.get_document(doc_uuid) doc = await db.get_document(doc_uuid)
if not doc: if not doc:
return json.dumps({"status": "error", return err(f"מסמך {doc_id} לא נמצא.")
"message": f"מסמך {doc_id} לא נמצא."},
ensure_ascii=False, indent=2)
if doc.get("case_id") != case["id"]: if doc.get("case_id") != case["id"]:
return json.dumps({"status": "error", return err(f"מסמך {doc_id} לא שייך לתיק {case_number}.")
"message": f"מסמך {doc_id} לא שייך לתיק {case_number}."},
ensure_ascii=False, indent=2)
updates: dict = {} updates: dict = {}
if doc_type: if doc_type:
if doc_type not in ALLOWED_DOC_TYPES: if doc_type not in ALLOWED_DOC_TYPES:
return json.dumps({ return err(f"doc_type לא תקין: {doc_type}",
"status": "error", data={"allowed": sorted(ALLOWED_DOC_TYPES)})
"message": f"doc_type לא תקין: {doc_type}",
"allowed": sorted(ALLOWED_DOC_TYPES),
}, ensure_ascii=False, indent=2)
updates["doc_type"] = doc_type updates["doc_type"] = doc_type
# appraiser_side is optional. The MCP tool can't distinguish "skip" from # 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). # To clear, the operator must edit metadata directly (rare).
if appraiser_side: if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES: if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return json.dumps({ return err(f"appraiser_side לא תקין: {appraiser_side}",
"status": "error", data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
"message": f"appraiser_side לא תקין: {appraiser_side}",
"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s),
}, ensure_ascii=False, indent=2)
metadata = doc.get("metadata") or {} metadata = doc.get("metadata") or {}
if isinstance(metadata, str): if isinstance(metadata, str):
metadata = json.loads(metadata) metadata = json.loads(metadata)
@@ -490,14 +475,12 @@ async def document_update(
updates["metadata"] = metadata updates["metadata"] = metadata
if not updates: if not updates:
return json.dumps({"status": "noop", "message": "אין שינוי לבצע."}, return ok({"noop": True}, message="אין שינוי לבצע.")
ensure_ascii=False, indent=2)
await db.update_document(doc_uuid, **updates) await db.update_document(doc_uuid, **updates)
fresh = await db.get_document(doc_uuid) fresh = await db.get_document(doc_uuid)
return json.dumps({ return ok({
"status": "completed",
"doc_id": doc_id, "doc_id": doc_id,
"doc_type": fresh.get("doc_type"), "doc_type": fresh.get("doc_type"),
"metadata": fresh.get("metadata"), "metadata": fresh.get("metadata"),
}, default=str, ensure_ascii=False, indent=2) })

View File

@@ -14,9 +14,8 @@ decisions and enforces the required metadata at the tool boundary.
from __future__ import annotations from __future__ import annotations
import json
from legal_mcp.services import internal_decisions as int_svc 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 Hebrew district names (matches _COURT_TO_DISTRICT in service)
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"} VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
@@ -26,14 +25,6 @@ VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'} 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( async def internal_decision_upload(
file_path: str, file_path: str,
case_number: str, case_number: str,

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import argument_aggregator, db 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( 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
result = await argument_aggregator.aggregate_claims_to_arguments( result = await argument_aggregator.aggregate_claims_to_arguments(
case_id, force=force, case_id, force=force,
) )
result["case_number"] = case_number result["case_number"] = case_number
return json.dumps(result, ensure_ascii=False, indent=2, default=str) return ok(result)
async def get_legal_arguments( async def get_legal_arguments(
@@ -46,21 +43,16 @@ async def get_legal_arguments(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
args = await argument_aggregator.get_legal_arguments(case_id, party=party) args = await argument_aggregator.get_legal_arguments(case_id, party=party)
if not args: if not args:
return json.dumps({ return empty(
"status": "empty", "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
"case_number": case_number, data={"case_number": case_number, "arguments": []},
"message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.", )
"arguments": [],
}, ensure_ascii=False, indent=2)
# Group by party for nicer display. # Group by party for nicer display.
party_he = { party_he = {
@@ -75,9 +67,8 @@ async def get_legal_arguments(
label = party_he.get(a["party"], a["party"]) label = party_he.get(a["party"], a["party"])
by_party.setdefault(label, []).append(a) by_party.setdefault(label, []).append(a)
return json.dumps({ return ok({
"status": "ok",
"case_number": case_number, "case_number": case_number,
"total": len(args), "total": len(args),
"by_party": by_party, "by_party": by_party,
}, ensure_ascii=False, indent=2, default=str) })

View File

@@ -18,18 +18,10 @@ Three tools:
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
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 _resolve_case_id(case_number: str) -> UUID | None: async def _resolve_case_id(case_number: str) -> UUID | None:

View File

@@ -17,19 +17,11 @@ the chair approves them — per project review policy.
from __future__ import annotations from __future__ import annotations
import json
import time import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, precedent_library, telemetry from legal_mcp.services import db, precedent_library, telemetry
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope
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 precedent_library_upload( async def precedent_library_upload(
@@ -293,7 +285,7 @@ async def search_precedent_library(
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}. Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False) return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
q = query.strip() q = query.strip()
t0 = time.perf_counter() t0 = time.perf_counter()
results = await precedent_library.search_library( results = await precedent_library.search_library(

View File

@@ -7,11 +7,10 @@ free-text citations the chair attaches during the compose phase.
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
async def precedent_attach( async def precedent_attach(
@@ -34,14 +33,14 @@ async def precedent_attach(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False) return err(f"תיק {case_number} לא נמצא.")
pdf_uuid: UUID | None = None pdf_uuid: UUID | None = None
if pdf_document_id: if pdf_document_id:
try: try:
pdf_uuid = UUID(pdf_document_id) pdf_uuid = UUID(pdf_document_id)
except ValueError: 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). # 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. # 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 if (_p.get("citation") == citation and _p.get("quote") == quote
and (_p.get("section_id") or None) == (section_id or None)): and (_p.get("section_id") or None) == (section_id or None)):
_p["idempotent_existing"] = True _p["idempotent_existing"] = True
return json.dumps(_p, ensure_ascii=False, indent=2, default=str) return ok(_p)
row = await db.create_case_precedent( row = await db.create_case_precedent(
case_id=UUID(case["id"]), case_id=UUID(case["id"]),
@@ -60,17 +59,17 @@ async def precedent_attach(
pdf_document_id=pdf_uuid, pdf_document_id=pdf_uuid,
practice_area=case.get("practice_area"), 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: async def precedent_list(case_number: str) -> str:
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה.""" """רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: 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"])) 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: async def precedent_remove(precedent_id: str) -> str:
@@ -78,12 +77,10 @@ async def precedent_remove(precedent_id: str) -> str:
try: try:
pid = UUID(precedent_id) pid = UUID(precedent_id)
except ValueError: except ValueError:
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False) return err("precedent_id לא תקין")
ok = await db.delete_case_precedent(pid) deleted = await db.delete_case_precedent(pid)
return json.dumps( return ok({"deleted": deleted, "precedent_id": precedent_id})
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
)
async def precedent_search_library( async def precedent_search_library(
@@ -97,7 +94,7 @@ async def precedent_search_library(
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
""" """
if not query or len(query.strip()) < 2: 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) 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)

View File

@@ -15,18 +15,10 @@ CLI is available, and the row gets enriched.
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, style_metadata_extractor from legal_mcp.services import db, style_metadata_extractor
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
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)
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str: async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from uuid import UUID from uuid import UUID
@@ -12,6 +11,7 @@ from legal_mcp.services.lessons import (
VALID_OUTCOMES, VALID_OUTCOMES,
canonical_outcome, canonical_outcome,
) )
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
logger = logging.getLogger(__name__) 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(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), "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]: 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: if case_number:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
result = await metrics.get_case_metrics(UUID(case["id"])) result = await metrics.get_case_metrics(UUID(case["id"]))
else: else:
result = await metrics.get_dashboard() result = await metrics.get_dashboard()
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
async def processing_status() -> str: async def processing_status() -> str:
@@ -135,14 +135,14 @@ async def processing_status() -> str:
corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus") corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns") pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
return json.dumps({ return ok({
"cases": case_count, "cases": case_count,
"documents": doc_count, "documents": doc_count,
"pending_processing": pending_count, "pending_processing": pending_count,
"chunks": chunk_count, "chunks": chunk_count,
"style_corpus_entries": corpus_count, "style_corpus_entries": corpus_count,
"style_patterns": pattern_count, "style_patterns": pattern_count,
}, ensure_ascii=False, indent=2) })
# ── Outcome & Brainstorming ─────────────────────────────────────── # ── Outcome & Brainstorming ───────────────────────────────────────
@@ -164,12 +164,12 @@ async def set_outcome(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
# GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical. # GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical.
outcome = canonical_outcome(outcome) outcome = canonical_outcome(outcome)
if outcome not in VALID_OUTCOMES: if outcome not in VALID_OUTCOMES:
return f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}" return err(f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -211,7 +211,7 @@ async def set_outcome(
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה." result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
result["next_step"] = "draft" result["next_step"] = "draft"
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
async def brainstorm_directions( async def brainstorm_directions(
@@ -226,14 +226,14 @@ async def brainstorm_directions(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
# Get existing decision for outcome # Get existing decision for outcome
decision = await db.get_decision_by_case(case_id) decision = await db.get_decision_by_case(case_id)
if not decision: if not decision:
return "לא הוזנה תוצאה לתיק. הפעל set_outcome קודם." return err("לא הוזנה תוצאה לתיק. הפעל set_outcome קודם.")
outcome = decision.get("outcome", "") outcome = decision.get("outcome", "")
reasoning = decision.get("outcome_reasoning", "") reasoning = decision.get("outcome_reasoning", "")
@@ -246,7 +246,7 @@ async def brainstorm_directions(
direction_doc={"brainstorm": directions, "approved": False}, direction_doc={"brainstorm": directions, "approved": False},
) )
return json.dumps(directions, default=str, ensure_ascii=False, indent=2) return ok(directions)
async def approve_direction( async def approve_direction(
@@ -265,18 +265,18 @@ async def approve_direction(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id) decision = await db.get_decision_by_case(case_id)
if not decision: if not decision:
return "לא הוזנה תוצאה לתיק." return err("לא הוזנה תוצאה לתיק.")
direction_data = decision.get("direction_doc") or {} direction_data = decision.get("direction_doc") or {}
brainstorm_result = direction_data.get("brainstorm", {}) brainstorm_result = direction_data.get("brainstorm", {})
if not brainstorm_result.get("directions"): if not brainstorm_result.get("directions"):
return "לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם." return err("לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם.")
direction_doc = brainstorm.build_direction_doc( direction_doc = brainstorm.build_direction_doc(
outcome=decision.get("outcome", ""), outcome=decision.get("outcome", ""),
@@ -288,11 +288,8 @@ async def approve_direction(
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc) await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
return json.dumps({ return ok({"direction": direction_doc},
"status": "approved", message="כיוון אושר. ניתן להתחיל כתיבת טיוטה.")
"message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.",
"direction": direction_doc,
}, default=str, ensure_ascii=False, indent=2)
async def ingest_final_version( async def ingest_final_version(
@@ -311,7 +308,7 @@ async def ingest_final_version(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -321,12 +318,12 @@ async def ingest_final_version(
final_text, _, _ = await extractor.extract_text(file_path) final_text, _, _ = await extractor.extract_text(file_path)
if not final_text: if not final_text:
return "לא סופק טקסט — יש לספק file_path או final_text." return err("לא סופק טקסט — יש לספק file_path או final_text.")
try: try:
result = await learning_loop.process_final_version(case_id, final_text) result = await learning_loop.process_final_version(case_id, final_text)
except ValueError as e: 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). # Auto-ingest into internal committee decisions corpus (best-effort).
try: try:
@@ -346,7 +343,7 @@ async def ingest_final_version(
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e) logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
result["internal_corpus_ingested"] = False result["internal_corpus_ingested"] = False
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
# ── Chair feedback tools ────────────────────────────────────────── # ── Chair feedback tools ──────────────────────────────────────────
@@ -376,7 +373,7 @@ async def record_chair_feedback(
"factual_error", "style", "other", "factual_error", "style", "other",
] ]
if category not in valid_categories: if category not in valid_categories:
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}" return err(f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback( feedback_id = await db.record_chair_feedback(
case_id=case_id, case_id=case_id,
@@ -386,15 +383,13 @@ async def record_chair_feedback(
lesson_extracted=lesson_extracted, lesson_extracted=lesson_extracted,
) )
return json.dumps({ return ok({
"status": "ok",
"feedback_id": str(feedback_id), "feedback_id": str(feedback_id),
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
"next_steps": [ "next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback", "כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback", "כדי לסמן כמטופל: resolve_chair_feedback",
], ],
}, ensure_ascii=False, indent=2) }, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
async def list_chair_feedback( async def list_chair_feedback(
@@ -425,7 +420,7 @@ async def list_chair_feedback(
) )
if not feedbacks: if not feedbacks:
return "אין הערות שמתאימות לסינון." return empty("אין הערות שמתאימות לסינון.")
items = [] items = []
for fb in feedbacks: 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, "date": fb["created_at"].isoformat() if fb.get("created_at") else None,
}) })
return json.dumps({ return ok({
"total": len(items), "total": len(items),
"feedbacks": items, "feedbacks": items,
}, ensure_ascii=False, indent=2, default=str) })

View File

@@ -234,14 +234,15 @@ def test_mcp_precedent_upload_rejects_arar_citation() -> None:
"ARAR 8126-25 ב. קרן-נכסים", "ARAR 8126-25 ב. קרן-נכסים",
): ):
result = loop.run_until_complete(call(citation)) 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}" f"expected guard to reject {citation!r}, got {result!r}"
) )
# The error message should mention internal_decision_upload so # The error message should mention internal_decision_upload so
# the caller knows the alternative path. # 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"error message should redirect to internal_decision_upload, "
f"got {result['error']!r}" f"got {result['message']!r}"
) )
finally: finally:
loop.close() loop.close()

View File

@@ -1896,7 +1896,9 @@ async def api_case_create(req: CaseCreateRequest):
appeal_subtype=req.appeal_subtype, appeal_subtype=req.appeal_subtype,
proceeding_type=req.proceeding_type, 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-create Paperclip project for the new case. case_create may have
# auto-derived appeal_subtype from the case-number prefix; prefer the # 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): async def api_case_get(case_number: str):
"""Get full case details including documents.""" """Get full case details including documents."""
result = await cases_tools.case_get(case_number) result = await cases_tools.case_get(case_number)
try: parsed = json.loads(result)
return json.loads(result) if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48
except json.JSONDecodeError: raise HTTPException(404, parsed.get("message") or result)
raise HTTPException(404, result) return envelope_unwrap(parsed)
@app.put("/api/cases/{case_number}") @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: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))
try: parsed = json.loads(result)
parsed = json.loads(result) if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48
except json.JSONDecodeError: raise HTTPException(404, parsed.get("message") or result)
raise HTTPException(404, result) parsed = envelope_unwrap(parsed)
# Paperclip sync: update project name when title changes (fire-and-forget). # Paperclip sync: update project name when title changes (fire-and-forget).
old_title = (existing or {}).get("title", "") 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. FK ON DELETE CASCADE; audit_log rows nullify their case_id.
Pass `remove_files=true` to also rm -rf the on-disk case directory.""" Pass `remove_files=true` to also rm -rf the on-disk case directory."""
result = await cases_tools.case_delete(case_number, remove_files) 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"): 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 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): async def api_case_status(case_number: str):
"""Get full workflow status for a case.""" """Get full workflow status for a case."""
result = await workflow_tools.workflow_status(case_number) result = await workflow_tools.workflow_status(case_number)
try: parsed = json.loads(result)
return json.loads(result) if isinstance(parsed, dict) and parsed.get("status") == "error": # GAP-48
except json.JSONDecodeError: raise HTTPException(404, parsed.get("message") or result)
raise HTTPException(404, result) return envelope_unwrap(parsed)
@app.get("/api/search") @app.get("/api/search")
@@ -2176,7 +2178,7 @@ async def api_case_template(case_number: str):
async def api_processing_status(): async def api_processing_status():
"""Get overall processing status.""" """Get overall processing status."""
result = await workflow_tools.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") @app.get("/api/system/diagnostics")
@@ -2965,10 +2967,10 @@ async def api_precedent_attach(case_number: str, req: PrecedentCreateRequest):
chair_note=req.chair_note, chair_note=req.chair_note,
pdf_document_id=req.pdf_document_id, pdf_document_id=req.pdf_document_id,
) )
data = json.loads(result) parsed = json.loads(result) # GAP-48
if data.get("error"): if parsed.get("status") == "error":
raise HTTPException(404, data["error"]) raise HTTPException(404, parsed.get("message") or "")
return data return envelope_unwrap(parsed)
@app.post("/api/cases/{case_number}/precedents/upload-pdf") @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): async def api_precedent_list(case_number: str):
"""List all precedents attached to a case, grouped client-side by section_id.""" """List all precedents attached to a case, grouped client-side by section_id."""
result = await precedents_tools.precedent_list(case_number) result = await precedents_tools.precedent_list(case_number)
data = json.loads(result) parsed = json.loads(result) # GAP-48
if isinstance(data, dict) and data.get("error"): if isinstance(parsed, dict) and parsed.get("status") == "error":
raise HTTPException(404, data["error"]) raise HTTPException(404, parsed.get("message") or "")
return data return envelope_unwrap(parsed)
@app.delete("/api/precedents/{precedent_id}") @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 in the documents table — orphaned references nullify via FK
ON DELETE SET NULL — so we keep the audit trail of the file.""" ON DELETE SET NULL — so we keep the audit trail of the file."""
result = await precedents_tools.precedent_remove(precedent_id) result = await precedents_tools.precedent_remove(precedent_id)
data = json.loads(result) parsed = json.loads(result) # GAP-48
if data.get("error"): if parsed.get("status") == "error":
raise HTTPException(400, data["error"]) raise HTTPException(400, parsed.get("message") or "")
data = envelope_unwrap(parsed)
if not data.get("deleted"): if not data.get("deleted"):
raise HTTPException(404, "לא נמצא") raise HTTPException(404, "לא נמצא")
return data 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): async def api_precedent_search(q: str, practice_area: str = "", limit: int = 10):
"""Cross-case library typeahead. Returns one row per distinct citation.""" """Cross-case library typeahead. Returns one row per distinct citation."""
result = await precedents_tools.precedent_search_library(q, practice_area, limit) 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 ── # ── Exports API — drafts, versions, download, upload, mark-final ──
@@ -3464,9 +3470,10 @@ async def api_start_workflow(case_number: str):
""" """
# 1. Verify case exists and status is appropriate # 1. Verify case exists and status is appropriate
case_raw = await cases_tools.case_get(case_number) case_raw = await cases_tools.case_get(case_number)
case_data = json.loads(case_raw) case_env = json.loads(case_raw) # GAP-48
if "error" in case_data: if isinstance(case_env, dict) and case_env.get("status") == "error":
raise HTTPException(404, f"תיק {case_number} לא נמצא") raise HTTPException(404, f"תיק {case_number} לא נמצא")
case_data = envelope_unwrap(case_env)
status = case_data.get("status", "") status = case_data.get("status", "")
allowed = {"new", "documents_ready"} allowed = {"new", "documents_ready"}