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:
@@ -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 אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס**
|
||||
|
||||
@@ -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 · **תלויות:** —
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
70
web/app.py
70
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")
|
||||
|
||||
Reference in New Issue
Block a user