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

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