feat(mcp): FU-14 GAP-48 פרוסה 3 — envelope למשפחת drafting (סגירת GAP-48)

הפרוסה האחרונה של GAP-48 (INV-TOOL1). 18 כלי drafting הומרו ל-{status,data,message}
דרך tools/envelope.py — כולל מסלול הפקת-ההחלטה הקריטי.

עיקרון לכלים עם כשל משמעותי (export_docx/revise_draft/apply_user_edit): err()
ברמת-המעטפת — כך שהסוכן והמשתמש רואים את הכשל; failed_gates רוכב ב-data.
שאר הכלים: ok(data=payload) להצלחה, err להיעדר-תיק/קלט-שגוי/חריגה.

6 צרכני-app.py חוּוטו (get_decision_template, apply_user_edit ×2, revise_draft,
list_bookmarks, export_docx) עם envelope_unwrap + בדיקת status=="error"→4xx,
לשמירת חוזה-ה-API (X6) ללא-שינוי. test_export_qa_gate עודכן לחוזה החדש.

בדיקות: 182/182 עוברים (כולל שערי-QA של הייצוא).

GAP-48 סגור: כל ~12 משפחות-הכלים אחידות. נותר ב-FU-14: GAP-49/50 (שובר), GAP-54.

Invariants: משלים INV-TOOL1 + G2. מתועד ב-X9 (נסגר) + gap-audit פרוסה 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 17:51:56 +00:00
parent 9a3e7faf08
commit 29af008271
5 changed files with 105 additions and 150 deletions

View File

@@ -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"<!-- פתיחת דיון: {opening['description']} -->\n"
f"<!-- סיכום: {summary['description']} -->\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:
"<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n"
f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\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)
})

View File

@@ -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"