Files
legal-ai/mcp-server/tests/test_protocol_analyzer.py
Chaim f4ce8332fe
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)
analyze_protocol בחר את מסמך-הפרוטוקול הראשון (doc_type='protocol') והתעלם
מ-protocol_scope. בתיק עם כמה פרוטוקולים — נספח ועדה-מקומית + דיון ועדת-הערר —
הוא ניתח את הלא-נכון (התגלה ב-1043-02-26: ניתח "נספח 18 השתלשלות פרוטוקולים"
במקום "פרוטוקול דיון 23.6.26").

- _find_protocol מעדיף פרוטוקול ועדת-ערר (scope שאינו 'lower'); פרוטוקול
  'lower' (ועדה מקומית/מחוזית) לא נכנס להשוואה — הוא רקע לבלוק ו בלבד.
- כמה פרוטוקולי-ערר → האחרון (created_at) גובר; document_id מכוון במפורש.
- analyze_protocol (service + drafting tool + MCP tool) מקבל document_id/
  target_document_id אופציונלי.
- _protocol_scope: קורא metadata.protocol_scope (ריק=appeal, כמו ה-UI).
- 5 בדיקות חדשות: דילוג-lower, all-lower→None, most-recent, targeting, unknown-id.

טווח: Fix1 (קוד). ניקוי 1043 (תיוג נספח 18 כ-lower + מחיקת 11 רשומות שגויות
+ הרצה מכוונת ל-23.6.26) דורש טעינה-מחדש של ה-MCP — אחרי מיזוג. פרוצדורת
ניתוח-מסמך-נוסף הכללית — המשך ב-#223.

Invariants: G1 (נרמול-במקור — קריאת scope מ-metadata, לא ניחוש-בקריאה) ·
G2 (אותו נתיב ניתוח, בורר מדויק) · §6 (no_protocol מחזיר סטטוס מפורש, לא בליעה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:59:30 +00:00

201 lines
7.4 KiB
Python

"""Tests for the comparative protocol analyzer (WS4 / #203).
Covers the pure (non-LLM, non-DB) logic: the anti-hallucination normalization
gate, protocol-document discovery, claim_type tagging for protocols, and the
block-ז filter that keeps oral hearing arguments out of the original-pleadings
summary (INV-WR4).
"""
from __future__ import annotations
import pytest
from legal_mcp.services import protocol_analyzer as pa
from legal_mcp.services.claims_extractor import _infer_claim_type
# ── claim_type tagging — protocol distinct from written pleadings ───────────
@pytest.mark.parametrize("doc_type,title,expected", [
("protocol", "פרוטוקול דיון", "protocol"),
("appeal", "פרוטוקול הדיון מיום 1.1", "protocol"), # title-based
("appeal", "כתב ערר", "claim"),
("response", "כתב תשובה", "response"),
("response", "תגובת המשיבה", "reply"),
])
def test_infer_claim_type(doc_type, title, expected):
assert _infer_claim_type(doc_type, title) == expected
# ── _find_protocol ─────────────────────────────────────────────────────────
def test_find_protocol_by_doc_type():
docs = [
{"id": "1", "doc_type": "appeal", "title": "כתב ערר"},
{"id": "2", "doc_type": "protocol", "title": "פרוטוקול"},
]
assert pa._find_protocol(docs)["id"] == "2"
def test_find_protocol_by_title_fallback():
docs = [
{"id": "1", "doc_type": "reference", "title": "פרוטוקול הדיון"},
{"id": "2", "doc_type": "appeal", "title": "כתב ערר"},
]
assert pa._find_protocol(docs)["id"] == "1"
def test_find_protocol_none():
docs = [{"id": "1", "doc_type": "appeal", "title": "כתב ערר"}]
assert pa._find_protocol(docs) is None
def test_find_protocol_skips_lower_scope():
# A local/district-committee protocol (scope='lower') must NOT be picked for
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
docs = [
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "appeal"
def test_find_protocol_all_lower_returns_none():
# If every protocol is lower-scoped there is no ערר-hearing to compare.
docs = [
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
"metadata": {"protocol_scope": "lower"}},
]
assert pa._find_protocol(docs) is None
def test_find_protocol_prefers_most_recent_appeal():
docs = [
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
"metadata": {}, "created_at": "2026-03-01"},
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "new"
def test_find_protocol_explicit_document_id_overrides():
# Explicit target wins even over scope/recency heuristics — including the
# ability to point at a lower-scoped doc if the caller insists.
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
"created_at": "2026-01-01"},
]
from uuid import UUID
picked = pa._find_protocol(
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
)
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
def test_find_protocol_unknown_document_id_returns_none():
from uuid import UUID
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול", "metadata": {}},
]
assert pa._find_protocol(
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
) is None
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
_AID = "11111111-1111-1111-1111-111111111111"
def test_normalize_change_valid():
row = pa._normalize_change(
{
"change_type": "dropped",
"party_role": "appellant",
"argument_id": _AID,
"argument_title": "טענת השיהוי",
"summary": "העורר ויתר על הטענה בדיון.",
"sharpened_question": "האם נותרה טענת סף?",
"evidence_quote": "ב\"כ העורר: איננו עומדים על טענת השיהוי.",
"page_number": 3,
},
valid_argument_ids={_AID},
)
assert row is not None
assert row["change_type"] == "dropped"
assert str(row["argument_id"]) == _AID
assert row["page_number"] == 3
def test_normalize_change_drops_row_without_evidence_quote():
# quote-or-retract: no verbatim support → row is rejected at source.
row = pa._normalize_change(
{"change_type": "strengthened", "summary": "חוזקה", "evidence_quote": ""},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_drops_row_without_summary():
row = pa._normalize_change(
{"change_type": "dropped", "summary": "", "evidence_quote": "ציטוט"},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_rejects_bad_change_type():
row = pa._normalize_change(
{"change_type": "modified", "summary": "x", "evidence_quote": "y"},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_drops_hallucinated_argument_id():
# An argument_id that isn't one of this case's arguments must be dropped
# (FK safety) — but the row itself, being newly_raised-shaped, survives.
row = pa._normalize_change(
{
"change_type": "newly_raised",
"party_role": "respondent",
"argument_id": "99999999-9999-9999-9999-999999999999",
"summary": "סוגיה חדשה שעלתה בדיון.",
"evidence_quote": "ב\"כ המשיבה העלה לראשונה את שאלת הסמכות.",
},
valid_argument_ids={_AID},
)
assert row is not None
assert row["argument_id"] is None
assert row["change_type"] == "newly_raised"
def test_normalize_change_invalid_party_role_blanked():
row = pa._normalize_change(
{
"change_type": "strengthened",
"party_role": "judge", # not a valid party
"summary": "חוזקה הטענה.",
"evidence_quote": "ציטוט מבסס.",
},
valid_argument_ids=set(),
)
assert row is not None
assert row["party_role"] == ""
def test_normalize_change_truncates_quote():
long_quote = "א" * 500
row = pa._normalize_change(
{"change_type": "dropped", "summary": "x", "evidence_quote": long_quote},
valid_argument_ids=set(),
)
assert row is not None
assert len(row["evidence_quote"]) == 200