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>
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
"""Domain-scope tests for search_decisions (GAP-12 / INV-RET1).
|
|
|
|
Policy under test (see CLAUDE.md + tools/search.py):
|
|
1. explicit practice_area -> used as-is, search runs;
|
|
2. case_number + case.practice_area set -> use case value, runs;
|
|
3. case_number + empty case.practice_area but derivable prefix (8xxx)
|
|
-> derive domain, runs;
|
|
4. case_number present but UNDETERMINABLE (case.practice_area empty AND
|
|
prefix not 1/8/9) -> BLOCK (return Hebrew error, hybrid search
|
|
NEVER called);
|
|
5. no case_number, no practice_area -> warn + proceed (runs).
|
|
|
|
All DB / embedding / hybrid-search / telemetry calls are monkeypatched so
|
|
the test runs fully offline with no live Postgres or model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from legal_mcp.services import db, embeddings, hybrid_search, telemetry
|
|
from legal_mcp.tools import search as search_tool
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
@pytest.fixture()
|
|
def patched(monkeypatch: pytest.MonkeyPatch) -> dict:
|
|
"""Patch all I/O boundaries. Record what hybrid_search received.
|
|
|
|
``calls["hybrid"]`` is appended to ONLY when the real search runs, so
|
|
asserting ``calls["hybrid"] == []`` proves the search was blocked.
|
|
"""
|
|
calls: dict = {"hybrid": [], "cases": {}}
|
|
|
|
async def _embed_query(query: str):
|
|
return [0.0] * 8
|
|
|
|
async def _search_documents_hybrid(**kwargs):
|
|
calls["hybrid"].append(kwargs)
|
|
# one synthetic hit so the formatting path is exercised
|
|
return [
|
|
{
|
|
"score": 0.9,
|
|
"case_number": "X",
|
|
"document_title": "doc",
|
|
"section_type": "facts",
|
|
"page_number": 1,
|
|
"content": "hit",
|
|
"match_type": "text",
|
|
"image_thumbnail_path": None,
|
|
}
|
|
]
|
|
|
|
async def _get_case_by_number(case_number: str):
|
|
return calls["cases"].get(case_number)
|
|
|
|
def _log_search_bg(**kwargs):
|
|
return None
|
|
|
|
monkeypatch.setattr(embeddings, "embed_query", _embed_query)
|
|
monkeypatch.setattr(
|
|
hybrid_search, "search_documents_hybrid", _search_documents_hybrid
|
|
)
|
|
monkeypatch.setattr(db, "get_case_by_number", _get_case_by_number)
|
|
monkeypatch.setattr(telemetry, "log_search_bg", _log_search_bg)
|
|
return calls
|
|
|
|
|
|
def test_explicit_practice_area_used(patched: dict) -> None:
|
|
out = _run(
|
|
search_tool.search_decisions(
|
|
query="זכויות בנייה", practice_area="betterment_levy"
|
|
)
|
|
)
|
|
assert len(patched["hybrid"]) == 1
|
|
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
|
|
# explicit value must not trigger a case lookup
|
|
assert patched["cases"] == {}
|
|
# ran -> JSON result, not an error string
|
|
assert json.loads(out)["status"] == "ok"
|
|
assert json.loads(out)["data"][0]["content"] == "hit"
|
|
|
|
|
|
def test_case_practice_area_used(patched: dict) -> None:
|
|
patched["cases"]["8126/25"] = {
|
|
"id": str(uuid4()),
|
|
"practice_area": "betterment_levy",
|
|
"appeal_subtype": "betterment_levy",
|
|
}
|
|
out = _run(
|
|
search_tool.search_decisions(query="היטל", case_number="8126/25")
|
|
)
|
|
assert len(patched["hybrid"]) == 1
|
|
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
|
|
assert json.loads(out)["status"] == "ok"
|
|
assert json.loads(out)["data"][0]["content"] == "hit"
|
|
|
|
|
|
def test_case_empty_practice_area_derived_from_prefix(patched: dict) -> None:
|
|
# case row exists but practice_area is empty -> derive from 8xxx prefix
|
|
patched["cases"]["8126/25"] = {
|
|
"id": str(uuid4()),
|
|
"practice_area": "",
|
|
"appeal_subtype": "",
|
|
}
|
|
out = _run(
|
|
search_tool.search_decisions(query="היטל", case_number="8126/25")
|
|
)
|
|
assert len(patched["hybrid"]) == 1
|
|
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
|
|
assert json.loads(out)["status"] == "ok"
|
|
assert json.loads(out)["data"][0]["content"] == "hit"
|
|
|
|
|
|
def test_case_undeterminable_is_blocked(patched: dict) -> None:
|
|
# case exists, empty practice_area, and prefix is NOT 1/8/9 -> block
|
|
patched["cases"]["7777/25"] = {
|
|
"id": str(uuid4()),
|
|
"practice_area": "",
|
|
"appeal_subtype": "",
|
|
}
|
|
out = _run(
|
|
search_tool.search_decisions(query="משהו", case_number="7777/25")
|
|
)
|
|
# hybrid search must NOT have been called
|
|
assert patched["hybrid"] == []
|
|
# GAP-48: returns the {status,data,message} envelope with status="error"
|
|
parsed = json.loads(out)
|
|
assert parsed["status"] == "error"
|
|
assert "7777/25" in parsed["message"]
|
|
|
|
|
|
def test_no_case_no_practice_area_proceeds(patched: dict) -> None:
|
|
# exploratory / chat search: cross-domain, must NOT be blocked
|
|
out = _run(search_tool.search_decisions(query="חיפוש חופשי"))
|
|
assert len(patched["hybrid"]) == 1
|
|
assert patched["hybrid"][0]["practice_area"] is None
|
|
assert patched["cases"] == {}
|
|
assert json.loads(out)["status"] == "ok"
|
|
assert json.loads(out)["data"][0]["content"] == "hit"
|