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