feat(mcp): FU-14 GAP-48 פרוסה 1 — envelope אחיד (SSoT) + משפחת-חיפוש
INV-TOOL1: כלי-ה-MCP החזירו 3 מוסכמות סותרות (raw payload / {error} /
{status,message} אד-הוק) + 5 עותקי _ok/_err משוכפלים. נוצר tools/envelope.py
כמקור-אמת יחיד: ok/empty/err → {status,data,message}, כש-status מבחין
מפורשות הצלחה/ריק/שגיאה.
פרוסה 1 ממירה את משפחת-החיפוש (search_decisions, search_case_documents,
find_similar_cases, search_internal_decisions). web/app.py מפרק את המעטפת
דרך envelope_unwrap כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי — תשובת ה-HTTP
זהה (list על hits, {"message"} על ריק/שגיאה). טסט test_search_domain_scope
עודכן לחוזה החדש (5/5 עוברים).
החלטה: הדרגתי לפי-משפחה ולא big-bang. מפת-צרכנים: server.py pass-through,
web-ui מבודד (/api/*), רק 17 כלים נצרכים ישירות מ-app.py → סיכון מינימלי
לסוכנים החיים. ~73 כלים נותרו לפרוסות הבאות.
Invariants: מקדם INV-TOOL1 (envelope עקבי) + G2 (SSoT, ביטול כפילות _ok/_err).
לא נוגע ב-G1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
58
mcp-server/src/legal_mcp/tools/envelope.py
Normal file
58
mcp-server/src/legal_mcp/tools/envelope.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""מעטפת-תשובה קנונית לכלי-ה-MCP (GAP-48 / INV-TOOL1).
|
||||
|
||||
מקור-אמת יחיד לצורת-הפלט של כל כלי. כל כלי שעבר מיגרציה מחזיר מחרוזת-JSON
|
||||
אחידה ``{status, data, message}`` — לא list-לפעמים, string-לפעמים, ``{error}``
|
||||
לפעמים. שלושת המצבים מובחנים מפורשות (INV-TOOL1):
|
||||
|
||||
status — "ok" הצלחה עם payload ב-``data``.
|
||||
"empty" שאילתה תקינה שלא החזירה תוצאות (מובחן מ-error).
|
||||
"error" הקריאה נכשלה; ``message`` מסביר.
|
||||
data — ה-payload (list/dict/scalar) או null.
|
||||
message — הערה קריאה-לאדם (עברית), בעיקר ל-empty/error.
|
||||
|
||||
מחליף את ה-``_ok``/``_err`` שהשתכפלו ב-5 קבצי-כלים עם 3 מוסכמות שונות (G2).
|
||||
|
||||
צרכן-API ב-``web/`` שמעביר פלט-כלי ל-HTTP חייב **לפרק** את המעטפת
|
||||
(להחזיר ``data``) כדי לשמר את חוזה-ה-UI↔API (X6) — ראה ``envelope_unwrap``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _dump(obj: dict) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "") -> str:
|
||||
"""הצלחה עם payload."""
|
||||
return _dump({"status": "ok", "data": data, "message": message})
|
||||
|
||||
|
||||
def empty(message: str = "", data: Any = None) -> str:
|
||||
"""שאילתה תקינה ללא תוצאות — מובחן מהצלחה (data לא-ריק) ומשגיאה."""
|
||||
return _dump({"status": "empty", "data": [] if data is None else data, "message": message})
|
||||
|
||||
|
||||
def err(message: str, *, data: Any = None) -> str:
|
||||
"""כשל בקריאה."""
|
||||
return _dump({"status": "error", "data": data, "message": message})
|
||||
|
||||
|
||||
def envelope_unwrap(parsed: Any) -> Any:
|
||||
"""פירוק מעטפת לצורך צרכן-API שמשמר חוזה-HTTP ישן.
|
||||
|
||||
בהינתן dict-מעטפת מפורסר (``json.loads`` של פלט-כלי שעבר מיגרציה):
|
||||
status=="ok" → מחזיר ``data`` (ה-payload המקורי, למשל list).
|
||||
status in {empty,error}→ מחזיר ``{"message": message}`` (תאימות-לאחור
|
||||
לצורה שצרכני-ה-API החזירו על מחרוזת-פרוזה).
|
||||
קלט שאינו מעטפת (אין מפתח ``status``) מוחזר כמות-שהוא — בטוח לתקופת-המעבר
|
||||
שבה רק חלק מהכלים עברו מיגרציה.
|
||||
"""
|
||||
if isinstance(parsed, dict) and "status" in parsed and "data" in parsed:
|
||||
if parsed["status"] == "ok":
|
||||
return parsed["data"]
|
||||
return {"message": parsed.get("message", "")}
|
||||
return parsed
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, embeddings, hybrid_search, practice_area as pa, telemetry
|
||||
from legal_mcp.tools.envelope import empty, err, ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +53,8 @@ async def search_decisions(
|
||||
# search to its domain. This is a data anomaly — BLOCK rather than
|
||||
# silently running a cross-domain search for a specific case.
|
||||
if not practice_area:
|
||||
return (
|
||||
f"שגיאה: לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק "
|
||||
return err(
|
||||
f"לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק "
|
||||
f"{case_number}. לתיק אין practice_area מוגדר ולא ניתן להסיק אותו "
|
||||
f"ממספר התיק. זוהי אנומליית נתונים — נא להגדיר את ה-practice_area "
|
||||
f"של התיק (למשל דרך case_update) לפני הרצת חיפוש מסונן לתיק זה."
|
||||
@@ -88,7 +88,7 @@ async def search_decisions(
|
||||
)
|
||||
|
||||
if not results:
|
||||
return "לא נמצאו תוצאות."
|
||||
return empty("לא נמצאו תוצאות.")
|
||||
|
||||
formatted = []
|
||||
for r in results:
|
||||
@@ -103,7 +103,7 @@ async def search_decisions(
|
||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
return ok(formatted)
|
||||
|
||||
|
||||
async def search_case_documents(
|
||||
@@ -120,7 +120,7 @@ async def search_case_documents(
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return f"תיק {case_number} לא נמצא."
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
|
||||
case_uuid = UUID(case["id"])
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
@@ -143,7 +143,7 @@ async def search_case_documents(
|
||||
)
|
||||
|
||||
if not results:
|
||||
return f"לא נמצאו תוצאות בתיק {case_number}."
|
||||
return empty(f"לא נמצאו תוצאות בתיק {case_number}.")
|
||||
|
||||
formatted = []
|
||||
for r in results:
|
||||
@@ -157,7 +157,7 @@ async def search_case_documents(
|
||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
return ok(formatted)
|
||||
|
||||
|
||||
async def find_similar_cases(
|
||||
@@ -216,7 +216,7 @@ async def find_similar_cases(
|
||||
)
|
||||
|
||||
if not results:
|
||||
return "לא נמצאו תיקים דומים."
|
||||
return empty("לא נמצאו תיקים דומים.")
|
||||
|
||||
# Deduplicate by case_number, keep best score per case.
|
||||
# image-only rows still carry case_number from the join.
|
||||
@@ -240,7 +240,7 @@ async def find_similar_cases(
|
||||
"match_type": r.get("match_type", "text"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
return ok(formatted)
|
||||
|
||||
|
||||
async def search_internal_decisions(
|
||||
@@ -296,7 +296,7 @@ async def search_internal_decisions(
|
||||
)
|
||||
|
||||
if not results:
|
||||
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
||||
return empty("לא נמצאו החלטות ועדת ערר רלוונטיות.")
|
||||
|
||||
# Cap primary results back to ``limit`` (we over-fetched only to seed
|
||||
# the citation expansion below — the user asked for ``limit`` items).
|
||||
@@ -334,7 +334,7 @@ async def search_internal_decisions(
|
||||
for row in cited_rows:
|
||||
formatted.append(_format_internal_row(row, match_type="cited_by"))
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
return ok(formatted)
|
||||
|
||||
|
||||
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:
|
||||
|
||||
Reference in New Issue
Block a user