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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user