diff --git a/docs/spec/X9-mcp-tool-contract.md b/docs/spec/X9-mcp-tool-contract.md index 86e0c1a..6754516 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` → `{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. +**אכיפה:** wrapper-תשובה משותף בכל הכלים — `tools/envelope.py` (`ok`/`empty`/`err` → `{status,data,message}`, status ∈ ok/empty/error — מבחין הצלחה/ריק/שגיאה), SSoT יחיד שמחליף את 5 ה-`_ok`/`_err` המשוכפלים. עיקרון: envelope-`status` משקף אם **הקריאה לכלי** הצליחה; תוצאות-עסקיות (failed_gates/results/...) נשמרות בתוך `data`. צרכני-API ב-`web/app.py` מפרקים דרך `envelope_unwrap` (+בדיקת `status=="error"`→4xx) כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי. **GAP-48 ✅ הושלם (2026-06-06):** כל ~12 משפחות-הכלים הומרו ל-envelope (search · precedent_library · citations · internal_decisions · missing_precedents · training_enrichment · precedents · legal_arguments · cases · documents · workflow · drafting). מסלול הפקת-ההחלטה (`export_docx` שער-QA) מאומת ב-`test_export_qa_gate`. 182/182 טסטים עוברים. +**הפרה ידועה:** — (נסגר) ### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס **כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס** diff --git a/docs/spec/gap-audit.md b/docs/spec/gap-audit.md index 6943640..5fb671e 100644 --- a/docs/spec/gap-audit.md +++ b/docs/spec/gap-audit.md @@ -203,7 +203,8 @@ - **סטטוס חלקי (פרוסה 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 כלים בפרוסות הבאות. -- **סטטוס חלקי (פרוסה 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 (איחוד קליטת-פסיקה). +- **סטטוס חלקי (פרוסה 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 כלים — מסלול הפקת-ההחלטה) בפרוסה נפרדת. +- **פרוסה 7, 2026-06-06 — ✅ GAP-48 הושלם.** משפחת `drafting` (18 כלים) הומרה ל-envelope. export_docx/revise_draft/apply_user_edit משתמשים ב-`err`-לכשל (כך שהסוכן והמשתמש רואים את הכשל ברמת-המעטפת), כש-`failed_gates` רוכב ב-`data`; 6 צרכני-app.py (get_decision_template/apply_user_edit×2/revise_draft/list_bookmarks/export_docx) חוּוטו עם בדיקת envelope-status; `test_export_qa_gate` עודכן לחוזה (182/182 עוברים). **GAP-48 סגור — כל ~12 המשפחות אחידות.** נותר ב-FU-14: GAP-49/50 (מיזוג+rename — שובר), GAP-54 (איחוד קליטת-פסיקה), GAP-47-חלק-ב (הנחיות-יו"ר→DB). ### FU-15 — deploy/env/secrets - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1–ENV5 · **effort:** M · **תלויות:** — diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 7b68e3a..46c4dd1 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -23,6 +23,7 @@ from legal_mcp.services.lessons import ( format_ratios_comment, get_lessons_for_outcome, ) +from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope # Fallback template for cases without expected_outcome DECISION_TEMPLATE = """# החלטה @@ -185,7 +186,7 @@ async def get_style_guide() -> str: result += f"- **פתיחה:** {_op['description']} ({_op['paragraphs'][0]}-{_op['paragraphs'][1]} פסקאות)\n" result += f"- **סיכום ({_sm['heading']}):** {_sm['description']}\n\n" - return result + return ok(result) async def draft_section( @@ -206,7 +207,7 @@ async def draft_section( """ 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"]) expected_outcome = case.get("expected_outcome", "") @@ -309,7 +310,7 @@ async def draft_section( context["drafting_guidance"] = guidance - return json.dumps(context, ensure_ascii=False, indent=2) + return ok(context) async def get_chair_directions(case_number: str) -> str: @@ -335,7 +336,7 @@ async def get_chair_directions(case_number: str) -> str: case_dir = config.find_case_dir(case_number) file_path = case_dir / "documents" / "research" / "analysis-and-research.md" result = research_md.extract_chair_directions(file_path) - return json.dumps(result, ensure_ascii=False, indent=2) + return ok(result) async def get_decision_template(case_number: str) -> str: @@ -348,7 +349,7 @@ async def get_decision_template(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} לא נמצא.") # GAP-51: canonicalize outcome + apply betterment_levy practice_area override. expected_outcome = canonical_outcome(case.get("expected_outcome", "")) @@ -386,7 +387,7 @@ async def get_decision_template(case_number: str) -> str: f"\n" f"\n\n" ) - return header + template + return ok(header + template) else: # Fallback to generic template template = DECISION_TEMPLATE.format(**format_args) @@ -395,7 +396,7 @@ async def get_decision_template(case_number: str) -> str: "\n" f"\n\n" ) + template - return template + return ok(template) async def validate_decision(case_number: str) -> str: @@ -408,15 +409,15 @@ async def validate_decision(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"]) try: result = await qa_validator.validate_decision(case_id) - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) except ValueError as e: - return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) + return err(str(e)) async def export_docx(case_number: str, output_path: str = "") -> str: @@ -433,7 +434,7 @@ async def export_docx(case_number: str, output_path: 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"]) @@ -441,21 +442,17 @@ async def export_docx(case_number: str, output_path: str = "") -> str: # fail (or before QA has been run at all). Gate on the STORED qa_results — # cheap SELECT, no LLM re-run. if not await db.qa_run_exists(case_id): - return json.dumps({ - "status": "error", - "message": "ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. " - "הרץ validate_decision לפני ייצוא.", - }, ensure_ascii=False, indent=2) + return err("ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. " + "הרץ validate_decision לפני ייצוא.") critical = await db.get_critical_qa_failures(case_id) if critical: gate_names = ", ".join(r["check_name"] for r in critical) - return json.dumps({ - "status": "error", - "message": f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). " - f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.", - "failed_gates": [r["check_name"] for r in critical], - }, ensure_ascii=False, indent=2) + return err( + f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). " + f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.", + data={"failed_gates": [r["check_name"] for r in critical]}, + ) try: path = await docx_exporter.export_decision(case_id, output_path or None) @@ -469,17 +466,13 @@ async def export_docx(case_number: str, output_path: str = "") -> str: case_dir = config.find_case_dir(case_number) if case_dir.exists(): git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}") - return json.dumps({ - "status": "completed", + return ok({ "path": path, "active_draft_path": path, "message": f"DOCX נוצר: {path}", - }, ensure_ascii=False, indent=2) + }) except ValueError as e: - return json.dumps({ - "status": "error", - "message": str(e), - }, ensure_ascii=False, indent=2) + return err(str(e)) # ── Interim draft (pre-ruling) ──────────────────────────────────── @@ -503,16 +496,13 @@ async def extract_appraiser_facts(case_number: str) -> str: 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"]) try: result = await appraiser_facts_extractor.extract_appraiser_facts(case_id) - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) except Exception as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def get_appraiser_facts(case_number: str) -> str: @@ -527,23 +517,19 @@ async def get_appraiser_facts(case_number: str) -> str: """ 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"]) try: facts = await db.list_appraiser_facts(case_id) conflicts = await db.detect_appraiser_conflicts(case_id) - return json.dumps({ - "status": "ok", + return ok({ "case_number": case_number, "count": len(facts), "facts": facts, "conflicts": conflicts, - }, default=str, ensure_ascii=False, indent=2) + }) except Exception as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def write_interim_draft(case_number: str, instructions: str = "") -> str: @@ -562,9 +548,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str: 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"]) # Make sure appraiser facts exist before writing block-tet (which depends on them). @@ -593,13 +577,12 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str: "error": str(e), }) - return json.dumps({ - "status": "completed", + return ok({ "blocks": results, "appraiser_facts_run": facts_run, "total_words": sum(r.get("word_count", 0) for r in results), "completed": sum(1 for r in results if r["status"] == "completed"), - }, default=str, ensure_ascii=False, indent=2) + }) async def export_interim_draft(case_number: str, output_path: str = "") -> str: @@ -615,9 +598,7 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str: 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"]) try: @@ -628,16 +609,14 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str: case_dir = config.find_case_dir(case_number) if case_dir.exists(): git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}") - return json.dumps({ - "status": "completed", + return ok({ "mode": "interim", "path": path, "active_draft_path": path, "message": f"טיוטת ביניים נוצרה: {path}", - }, ensure_ascii=False, indent=2) + }) except ValueError as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def apply_user_edit(case_number: str, edit_filename: str) -> str: @@ -656,17 +635,13 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str: 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"]) export_dir = config.find_case_dir(case_number) / "exports" edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename) if not edit_path.exists(): - return json.dumps({"status": "error", - "message": f"קובץ לא נמצא: {edit_path}"}, - ensure_ascii=False, indent=2) + return err(f"קובץ לא נמצא: {edit_path}") try: retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path) @@ -675,17 +650,15 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str: case_dir = config.find_case_dir(case_number) if case_dir.exists(): git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}") - return json.dumps({ - "status": "completed", + return ok({ "active_draft_path": str(edit_path), "bookmarks_added": retrofit_result.get("bookmarks_added", []), "missing_blocks": retrofit_result.get("missing_blocks", []), "structural_fallback": retrofit_result.get("structural_fallback", []), "existing_bookmarks": retrofit_result.get("existing_bookmarks", []), - }, ensure_ascii=False, indent=2) + }) except Exception as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def list_bookmarks(case_number: str) -> str: @@ -697,26 +670,20 @@ async def list_bookmarks(case_number: str) -> str: 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} לא נמצא.") active_path = await db.get_active_draft_path(UUID(case["id"])) if not active_path or not Path(active_path).exists(): - return json.dumps({"status": "no_active_draft", - "message": "לא נמצא active_draft. הרץ ייצוא או העלה עריכה."}, - ensure_ascii=False, indent=2) + return empty("לא נמצא active_draft. הרץ ייצוא או העלה עריכה.") try: names = docx_reviser.list_bookmarks(active_path) - return json.dumps({ - "status": "completed", + return ok({ "active_draft_path": active_path, "bookmarks": names, - }, ensure_ascii=False, indent=2) + }) except Exception as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def revise_draft(case_number: str, revisions_json: str, @@ -738,22 +705,17 @@ async def revise_draft(case_number: str, revisions_json: str, 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"]) active_path = await db.get_active_draft_path(case_id) if not active_path or not Path(active_path).exists(): - return json.dumps({"status": "error", - "message": "אין active_draft. הרץ ייצוא או apply_user_edit קודם."}, - ensure_ascii=False, indent=2) + return err("אין active_draft. הרץ ייצוא או apply_user_edit קודם.") try: raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json except json.JSONDecodeError as e: - return json.dumps({"status": "error", "message": f"JSON לא תקף: {e}"}, - ensure_ascii=False, indent=2) + return err(f"JSON לא תקף: {e}") revisions = [] for item in raw: @@ -792,8 +754,7 @@ async def revise_draft(case_number: str, revisions_json: str, case_dir, f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)", ) - return json.dumps({ - "status": "completed", + return ok({ "output_path": str(output_path), "version": next_ver, "applied": result.applied, @@ -803,10 +764,9 @@ async def revise_draft(case_number: str, revisions_json: str, {"id": r.id, "status": r.status, "error": r.error} for r in result.results ], - }, ensure_ascii=False, indent=2) + }) except Exception as e: - return json.dumps({"status": "error", "message": str(e)}, - ensure_ascii=False, indent=2) + return err(str(e)) async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str: @@ -821,14 +781,14 @@ async def get_block_context(case_number: str, block_id: str, instructions: 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"]) try: ctx = await block_writer.get_block_context(case_id, block_id, instructions) - return json.dumps(ctx, default=str, ensure_ascii=False, indent=2) + return ok(ctx) except ValueError as e: - return str(e) + return err(str(e)) async def save_block_content(case_number: str, block_id: str, content: str) -> str: @@ -843,14 +803,14 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s 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"]) try: result = await block_writer.save_block_content(case_id, block_id, content) - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) except ValueError as e: - return str(e) + return err(str(e)) async def analyze_style(appeal_subtype: str = "") -> str: @@ -863,7 +823,7 @@ async def analyze_style(appeal_subtype: str = "") -> str: from legal_mcp.services.style_analyzer import analyze_corpus result = await analyze_corpus(appeal_subtype) - return json.dumps(result, ensure_ascii=False, indent=2) + return ok(result) async def write_block( @@ -882,15 +842,15 @@ async def write_block( 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"]) try: result = await block_writer.write_and_store_block(case_id, block_id, instructions) - return json.dumps(result, default=str, ensure_ascii=False, indent=2) + return ok(result) except ValueError as e: - return str(e) + return err(str(e)) async def write_all_blocks( @@ -910,7 +870,7 @@ async def write_all_blocks( 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"]) @@ -944,8 +904,8 @@ async def write_all_blocks( break total_words = sum(r.get("word_count", 0) for r in results) - return json.dumps({ + return ok({ "blocks": results, "total_words": total_words, "completed": sum(1 for r in results if r["status"] == "completed"), - }, default=str, ensure_ascii=False, indent=2) + }) diff --git a/mcp-server/tests/test_export_qa_gate.py b/mcp-server/tests/test_export_qa_gate.py index 2a078d2..1d7d949 100644 --- a/mcp-server/tests/test_export_qa_gate.py +++ b/mcp-server/tests/test_export_qa_gate.py @@ -125,8 +125,9 @@ def test_export_blocked_when_critical_failures( monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical) out = json.loads(_run(drafting.export_docx("8001-24"))) + # GAP-48: {status,data,message} envelope; failed_gates rides in data. assert out["status"] == "error" - assert out["failed_gates"] == ["claims_coverage", "structural_integrity"] + assert out["data"]["failed_gates"] == ["claims_coverage", "structural_integrity"] assert "claims_coverage" in out["message"] assert patched_export["exported"] is False, "must not call the exporter" assert patched_export["committed"] is False, "must not git-commit" @@ -145,7 +146,8 @@ def test_export_proceeds_when_clean( monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical) out = json.loads(_run(drafting.export_docx("8001-24"))) - assert out["status"] == "completed", out - assert out["path"] == "/tmp/decision.docx" + # GAP-48: success is envelope status "ok"; payload (path) rides in data. + assert out["status"] == "ok", out + assert out["data"]["path"] == "/tmp/decision.docx" assert patched_export["exported"] is True, "clean QA must allow export" assert patched_export["set_draft"] is True, "active_draft_path must be set" diff --git a/web/app.py b/web/app.py index 818cd94..ec54b19 100644 --- a/web/app.py +++ b/web/app.py @@ -2169,9 +2169,10 @@ async def api_search_cases(q: str, limit: int = 10): async def api_case_template(case_number: str): """Get outcome-aware decision template for a case.""" result = await drafting_tools.get_decision_template(case_number) - if result.startswith("תיק"): - raise HTTPException(404, result) - return {"template": result} + parsed = json.loads(result) # GAP-48 + if parsed.get("status") == "error": + raise HTTPException(404, parsed.get("message") or "") + return {"template": envelope_unwrap(parsed)} @app.get("/api/processing-status") @@ -3145,7 +3146,11 @@ async def api_upload_export(case_number: str, file: UploadFile = File(...)): auto_result: dict = {"status": "ok"} try: raw = await drafting_tools.apply_user_edit(case_number, dest.name) - auto_result = json.loads(raw) + parsed = json.loads(raw) # GAP-48 + if parsed.get("status") == "error": + auto_result = {"status": "error", "message": parsed.get("message", "")} + else: + auto_result = {**(envelope_unwrap(parsed) or {}), "status": "completed"} except Exception as e: auto_result = {"status": "error", "message": str(e)} @@ -3174,37 +3179,29 @@ async def api_revise_draft(case_number: str, req: ReviseRequest): json.dumps(req.revisions, ensure_ascii=False), req.author, ) - try: - data = json.loads(raw) - except json.JSONDecodeError: - raise HTTPException(500, raw) - if data.get("status") == "error": - raise HTTPException(400, data.get("message", "revise failed")) - return data + parsed = json.loads(raw) # GAP-48 + if parsed.get("status") == "error": + raise HTTPException(400, parsed.get("message", "revise failed")) + return envelope_unwrap(parsed) @app.get("/api/cases/{case_number}/exports/bookmarks") async def api_list_bookmarks(case_number: str): """List bookmarks in the case's active draft (anchors for revisions).""" raw = await drafting_tools.list_bookmarks(case_number) - try: - data = json.loads(raw) - except json.JSONDecodeError: - raise HTTPException(500, raw) - return data + parsed = json.loads(raw) # GAP-48 + data = envelope_unwrap(parsed) + return data if isinstance(data, dict) else {"bookmarks": []} @app.post("/api/cases/{case_number}/exports/{filename}/retrofit") async def api_retrofit_bookmarks(case_number: str, filename: str): """Manually trigger retrofit of bookmarks on an existing file.""" raw = await drafting_tools.apply_user_edit(case_number, filename) - try: - data = json.loads(raw) - except json.JSONDecodeError: - raise HTTPException(500, raw) - if data.get("status") == "error": - raise HTTPException(400, data.get("message", "retrofit failed")) - return data + parsed = json.loads(raw) # GAP-48 + if parsed.get("status") == "error": + raise HTTPException(400, parsed.get("message", "retrofit failed")) + return envelope_unwrap(parsed) @app.get("/api/cases/{case_number}/active-draft") @@ -3323,26 +3320,21 @@ async def api_export_docx(case_number: str, background_tasks: BackgroundTasks): (markdown body + download link) to the linked issue. """ result = await drafting_tools.export_docx(case_number) - if isinstance(result, dict): - data = result - else: - try: - data = json.loads(result) - except (json.JSONDecodeError, TypeError): - # export_docx can also return a plain (non-JSON) string, e.g. - # "תיק ... לא נמצא." — surface it as a 500 with the raw text. - raise HTTPException(500, str(result)) + parsed = json.loads(result) # GAP-48: tool returns the {status,data,message} envelope # FU-6: a QA gate (or another error) can block the export. export_docx - # signals this with status == "error". Returning the existing 200 here - # would let the UI show a false "exported successfully" toast, so we map - # a block to 409 Conflict carrying the Hebrew message + failed_gates. - if isinstance(data, dict) and data.get("status") == "error": - detail = {"message": data.get("message", "ייצוא נחסם.")} - if data.get("failed_gates"): - detail["failed_gates"] = data["failed_gates"] + # signals this with envelope status == "error". Returning the existing 200 + # here would let the UI show a false "exported successfully" toast, so we map + # a block to 409 Conflict carrying the Hebrew message + failed_gates (in data). + if parsed.get("status") == "error": + detail = {"message": parsed.get("message", "ייצוא נחסם.")} + inner = parsed.get("data") or {} + if inner.get("failed_gates"): + detail["failed_gates"] = inner["failed_gates"] raise HTTPException(409, detail) + data = envelope_unwrap(parsed) # success payload: {path, active_draft_path, message} + # Notify the Paperclip plugin to attach the final-decision document. docx_filename = ( data.get("filename")