Files
legal-ai/mcp-server/tests/test_halacha_quality.py
Chaim f196bed564 feat(halacha): NLI entailment validator via claude_session (#81.3) + task #86
#81.3 — a post-extraction validator that flags halachot whose rule_statement is
NOT entailed by its supporting_quote (the model over-reaching beyond its source).

- Engine: claude_session-as-judge (local CLI, zero API cost) per chaim's standing
  preference — one batched judge call per chunk, NOT a hosted NLI model.
- Pure, unit-tested helpers in halacha_quality: NLI_SYSTEM, build_nli_prompt,
  parse_nli_verdicts (fails OPEN — any shape/label ambiguity → 'entailed').
- halacha_extractor._nli_check wraps the call; fails OPEN on any error (e.g. no
  CLI in the container) so a flaky judge never blocks a genuine halacha.
- Non-entailed (neutral/contradiction) → quality_flag 'nli_unsupported' which
  blocks auto-approve (routes to pending_review) via the existing store gate.
- config: HALACHA_NLI_ENABLED/MODEL/EFFORT (effort 'low' — entailment is simple).

Verified: suite 166 passed (10 new); LIVE smoke test against the real claude CLI
returned ['entailed','neutral'] for a supported vs unsupported rule.

Also commits TaskMaster #86 (Nevo preamble/ratio: anti-contamination strip fix +
gold-set benchmark) capturing today's strip_nevo_preamble findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:46:12 +00:00

149 lines
5.7 KiB
Python

from __future__ import annotations
import pytest
from legal_mcp.services import halacha_quality as hq
# ── non-decision / obiter ──
@pytest.mark.parametrize("text", [
"איני רואה לקבוע מסמרות בשאלה זו",
"אין צורך להכריע בטענה זו",
"למעלה מן הצורך נעיר כי",
"הערה זו ניתנת אגב אורחא",
])
def test_detect_non_decision_hits(text):
assert hq.detect_non_decision(text) is not None
@pytest.mark.parametrize("text", [
"בית המשפט קבע כי ההיתר בטל",
"ועדת הערר מוסמכת לדון בטענת סטייה מתכנית",
"",
])
def test_detect_non_decision_misses(text):
assert hq.detect_non_decision(text) is None
def test_non_decision_scans_all_fields():
# marker sits in the quote, not the abstracted rule
assert hq.detect_non_decision("כלל כללי", "", "וכאן אין צורך להכריע") is not None
# ── truncated quote ──
def test_truncated_dangling_letter():
assert hq.is_quote_truncated("ראוי כי תהיה השפעה על ה") is True
def test_truncated_empty():
assert hq.is_quote_truncated(" ") is True
@pytest.mark.parametrize("quote", [
"ועדת הערר היא הגוף המקצועי האמון על בחינת ההיבטים התכנוניים.",
"אין לועדה סמכות לסטות מתקנות התכנון והבניה", # no period, but full word
"ההיתר תואם את התכנית החלה על האיזור",
])
def test_not_truncated_complete_clauses(quote):
assert hq.is_quote_truncated(quote) is False
# ── thin restatement ──
def test_thin_restatement_near_copy():
quote = "ביטול היתר מחייב טעמים כבדי משקל של אינטרס ציבורי"
rule = "ביטול היתר מחייב טעמים כבדי משקל של אינטרס ציבורי"
assert hq.is_thin_restatement(rule, quote) is True
def test_not_thin_when_abstracted():
quote = "אין חולק כי אין לועדה סמכות לסטות מתקנות"
rule = ("ועדה מקומית לתכנון ובניה אינה מוסמכת לסטות מהוראות תקנות התכנון "
"והבניה, ובכלל זה מהוראות התוספת השנייה, ואין בידה ליתן היתר הסוטה מהן.")
assert hq.is_thin_restatement(rule, quote) is False
def test_thin_handles_empty():
assert hq.is_thin_restatement("", "something") is False
# ── aggregate flags + auto-approve gate semantics ──
def test_clean_halacha_no_flags():
rule = ("ועדת הערר מוסמכת לדון בערר על החלטה ליתן היתר בנייה גם כאשר נטען "
"כי ההיתר סוטה מתכנית, בהתאם למגמת תיקון 43 לחוק.")
quote = ("פרשנות מרחיבה המאפשרת הגשת ערר גם במקרה של מתן היתר כאשר נטען כי "
"ההיתר סוטה מתכנית הולמת את מגמת המחוקק בתיקון 43.")
assert hq.compute_quality_flags(rule, quote, "", quote_verified=True) == []
def test_flags_accumulate():
flags = hq.compute_quality_flags(
"כלל אגב אורחא על ה", "כלל אגב אורחא על ה",
quote_verified=False,
)
assert hq.FLAG_NON_DECISION in flags
assert hq.FLAG_TRUNCATED_QUOTE in flags
assert hq.FLAG_QUOTE_UNVERIFIED in flags
def test_normalize_text_quote_variants():
assert hq.normalize_text('עע"מ 317/10') == hq.normalize_text("עע״מ 317/10")
# ── #81.3 NLI entailment — pure prompt + parser ──
def test_build_nli_prompt_contains_pairs():
items = [
{"rule_statement": "כלל אלף", "supporting_quote": "ציטוט אלף"},
{"rule_statement": "כלל בית", "supporting_quote": "ציטוט בית"},
]
p = hq.build_nli_prompt(items)
assert "כלל אלף" in p and "ציטוט בית" in p
assert "זוג 1" in p and "זוג 2" in p
@pytest.mark.parametrize("raw,n,expected", [
(["entailed", "neutral"], 2, ["entailed", "neutral"]),
(["ENTAILED", "Contradiction"], 2, ["entailed", "contradiction"]), # case-insensitive
([{"verdict": "neutral"}, {"verdict": "entailed"}], 2, ["neutral", "entailed"]), # dict shape
(["entailed"], 2, ["entailed", "entailed"]), # length mismatch -> fail-open
(None, 2, ["entailed", "entailed"]), # non-list -> fail-open
(["bananas", "neutral"], 2, ["entailed", "neutral"]), # unknown label -> entailed
])
def test_parse_nli_verdicts(raw, n, expected):
assert hq.parse_nli_verdicts(raw, n) == expected
# ── _nli_check (async, via claude_session) — fail-open + verdict mapping ──
def test_nli_check_fail_open(monkeypatch):
import asyncio
from legal_mcp.services import halacha_extractor as he
async def boom(*a, **k):
raise RuntimeError("no claude CLI here")
monkeypatch.setattr(he.claude_session, "query_json", boom)
items = [{"rule_statement": "a", "supporting_quote": "b"}]
assert asyncio.run(he._nli_check(items)) == ["entailed"] # never blocks
def test_nli_check_maps_verdicts(monkeypatch):
import asyncio
from legal_mcp.services import halacha_extractor as he
async def fake(*a, **k):
return ["entailed", "neutral"]
monkeypatch.setattr(he.claude_session, "query_json", fake)
items = [{"rule_statement": "a", "supporting_quote": "b"},
{"rule_statement": "c", "supporting_quote": "d"}]
assert asyncio.run(he._nli_check(items)) == ["entailed", "neutral"]
def test_nli_check_empty():
import asyncio
from legal_mcp.services import halacha_extractor as he
assert asyncio.run(he._nli_check([])) == []