feat(halacha): strict-rubric quality gate + dedup-on-insert (#81,#82)
Bake the 2026-06-03 strict-cleanup rubric into the extraction pipeline so the corpus stays clean at the source instead of accumulating duplicates, obiter dicta, truncated quotes and thin restatements that clog the review queue. #81 — quality gate: - New pure module halacha_quality.py with unit-tested validators: non-decision/obiter (Wambaugh markers), truncated-quote (mid-word cut), thin-restatement (rule≈quote), quote-unverified. - Validators run in halacha_extractor._process; a non-decision is re-typed obiter; flags persist in new halachot.quality_flags column. - Auto-approve now requires confidence>=threshold AND no quality flags; flagged items route to pending_review regardless of confidence. - Both extraction prompts hardened: reject undecided dicta, exclude case-specific applications, require abstraction, forbid over-splitting. #82 — dedup-on-insert (store_halachot_for_chunk): - Within the same precedent, skip a halacha whose normalized supporting_quote already exists, or whose rule-embedding has cosine>=HALACHA_DEDUP_COSINE (0.93) against an already-stored one. Makes re-runs idempotent. Migration: halachot.quality_flags TEXT[] (additive, idempotent ALTER). Tests: 19 new unit tests; full suite 156 passed. Validated end-to-end against dev DB (dedup skips dups, flag blocks auto-approve, re-run inserts 0). Calibration: flags fire on only ~10% of current survivors (low false-positive). Spec: docs/halacha-strict-rubric.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,16 @@ HALACHA_AUTO_APPROVE_THRESHOLD = float(
|
||||
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
|
||||
)
|
||||
|
||||
# Halacha dedup-on-insert — within-precedent semantic cosine ceiling. Before
|
||||
# storing a halacha, store_halachot_for_chunk skips it if its rule-embedding has
|
||||
# cosine >= this value against an already-stored halacha of the SAME precedent
|
||||
# (exact normalized supporting_quote is always skipped regardless). 0.93 is the
|
||||
# conservative auto-skip floor: the 2026-06-03 cleanup showed the 0.90-0.95 band
|
||||
# is "almost entirely" same-rule-reworded, but auto-skip is unreviewed so we sit
|
||||
# just above the manual-cleanup 0.90 to avoid dropping a genuinely distinct
|
||||
# principle. Set > 1.0 to disable semantic dedup (exact-quote dedup still runs).
|
||||
HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
|
||||
|
||||
# Google Cloud Vision (OCR for scanned PDFs)
|
||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import asyncpg
|
||||
from pgvector.asyncpg import register_vector
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import halacha_quality
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -661,10 +662,14 @@ CREATE TABLE IF NOT EXISTS halachot (
|
||||
-- pending_review | approved | rejected | published
|
||||
reviewer TEXT DEFAULT '',
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
quality_flags TEXT[] DEFAULT '{}',
|
||||
-- non_decision | truncated_quote | thin_restatement | quote_unverified
|
||||
-- (any flag blocks auto-approve → routes to pending_review)
|
||||
embedding vector(1024),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
ALTER TABLE halachot ADD COLUMN IF NOT EXISTS quality_flags TEXT[] DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_halachot_case_law ON halachot(case_law_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_halachot_status ON halachot(review_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_halachot_practice ON halachot USING gin(practice_areas);
|
||||
@@ -3333,18 +3338,61 @@ async def store_halachot_for_chunk(
|
||||
across chunks never collide. The chunk is marked even when ``halachot`` is
|
||||
empty (so resume skips genuinely-empty chunks too). Caller serializes calls
|
||||
(a single in-process store-lock) so the MAX read stays race-free.
|
||||
|
||||
Two gates encode the strict rubric (docs/halacha-strict-rubric.md) so the
|
||||
corpus stays clean at the source instead of accumulating noise:
|
||||
|
||||
* Auto-approve gate — a halacha auto-approves only if confidence ≥ threshold
|
||||
AND it carries no ``quality_flags`` (non_decision / truncated_quote /
|
||||
thin_restatement / quote_unverified). Flagged items route to
|
||||
``pending_review`` regardless of confidence.
|
||||
* Dedup-on-insert — within the SAME precedent, a halacha is skipped if its
|
||||
normalized ``supporting_quote`` already exists, or its rule-embedding has
|
||||
cosine ≥ ``HALACHA_DEDUP_COSINE`` against an already-stored halacha.
|
||||
|
||||
Returns the number of halachot actually INSERTED (after dedup skips).
|
||||
"""
|
||||
threshold = config.HALACHA_AUTO_APPROVE_THRESHOLD
|
||||
dedup_distance = 1.0 - config.HALACHA_DEDUP_COSINE # cosine sim → distance
|
||||
pool = await get_pool()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
base = await conn.fetchval(
|
||||
"SELECT COALESCE(MAX(halacha_index), -1) + 1 FROM halachot "
|
||||
"WHERE case_law_id = $1", case_law_id,
|
||||
)
|
||||
for j, h in enumerate(halachot):
|
||||
# Existing normalized quotes for exact-dedup (incl. within-batch).
|
||||
existing_quotes = {
|
||||
halacha_quality.normalize_text(r["supporting_quote"])
|
||||
for r in await conn.fetch(
|
||||
"SELECT supporting_quote FROM halachot WHERE case_law_id = $1",
|
||||
case_law_id,
|
||||
)
|
||||
}
|
||||
for h in halachot:
|
||||
norm_quote = halacha_quality.normalize_text(h["supporting_quote"])
|
||||
# 1) exact normalized-quote duplicate within this precedent
|
||||
if norm_quote and norm_quote in existing_quotes:
|
||||
skipped += 1
|
||||
continue
|
||||
# 2) semantic near-duplicate (rule embedding cosine)
|
||||
emb = h.get("embedding")
|
||||
if emb is not None and config.HALACHA_DEDUP_COSINE <= 1.0:
|
||||
dup = await conn.fetchval(
|
||||
"SELECT 1 FROM halachot WHERE case_law_id = $1 "
|
||||
"AND embedding IS NOT NULL AND (embedding <=> $2) <= $3 "
|
||||
"LIMIT 1",
|
||||
case_law_id, emb, dedup_distance,
|
||||
)
|
||||
if dup:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
confidence = float(h.get("confidence", 0.0))
|
||||
auto_approve = confidence >= threshold
|
||||
flags = h.get("quality_flags") or []
|
||||
auto_approve = confidence >= threshold and not flags
|
||||
review_status = "approved" if auto_approve else "pending_review"
|
||||
reviewer = (
|
||||
f"auto-approved (confidence ≥ {threshold:.2f})"
|
||||
@@ -3356,22 +3404,29 @@ async def store_halachot_for_chunk(
|
||||
(case_law_id, halacha_index, rule_statement, rule_type,
|
||||
reasoning_summary, supporting_quote, page_reference,
|
||||
practice_areas, subject_tags, cites, confidence,
|
||||
quote_verified, embedding, review_status,
|
||||
quote_verified, quality_flags, embedding, review_status,
|
||||
reviewer, reviewed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, {reviewed_at_clause})""",
|
||||
case_law_id, base + j, h["rule_statement"],
|
||||
$12, $13, $14, $15, $16, {reviewed_at_clause})""",
|
||||
case_law_id, base + inserted, h["rule_statement"],
|
||||
h.get("rule_type", "binding"), h.get("reasoning_summary", ""),
|
||||
h["supporting_quote"], h.get("page_reference", ""),
|
||||
h.get("practice_areas", []), h.get("subject_tags", []),
|
||||
h.get("cites", []), confidence, h.get("quote_verified", False),
|
||||
h.get("embedding"), review_status, reviewer,
|
||||
flags, h.get("embedding"), review_status, reviewer,
|
||||
)
|
||||
existing_quotes.add(norm_quote)
|
||||
inserted += 1
|
||||
await conn.execute(
|
||||
"UPDATE precedent_chunks SET halacha_extracted_at = now() "
|
||||
"WHERE id = $1", chunk_id,
|
||||
)
|
||||
return len(halachot)
|
||||
if skipped:
|
||||
logger.info(
|
||||
"store_halachot_for_chunk: case_law=%s chunk=%s — %d inserted, "
|
||||
"%d skipped as duplicates", case_law_id, chunk_id, inserted, skipped,
|
||||
)
|
||||
return inserted
|
||||
|
||||
|
||||
async def list_halachot(
|
||||
@@ -3403,7 +3458,8 @@ async def list_halachot(
|
||||
SELECT h.id, h.case_law_id, h.halacha_index, h.rule_statement,
|
||||
h.rule_type, h.reasoning_summary, h.supporting_quote,
|
||||
h.page_reference, h.practice_areas, h.subject_tags,
|
||||
h.cites, h.confidence, h.quote_verified, h.review_status,
|
||||
h.cites, h.confidence, h.quote_verified, h.quality_flags,
|
||||
h.review_status,
|
||||
h.reviewer, h.reviewed_at, h.created_at, h.updated_at,
|
||||
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
||||
cl.precedent_level,
|
||||
|
||||
@@ -26,7 +26,9 @@ from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session, db, embeddings, proofreader
|
||||
from legal_mcp.services import (
|
||||
claude_session, db, embeddings, halacha_quality, proofreader,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,9 +87,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
|
||||
לא-הלכה (אין לחלץ):
|
||||
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
||||
- **סוגיה שהערכאה לא הכריעה בה** — אם בית המשפט אומר במפורש "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "למעלה מן הצורך", "אגב אורחא" — זו אינה הלכה. מבחן ההיפוך (Wambaugh): אם שלילת הכלל לא הייתה משנה את תוצאת הפסק — זו אמרת אגב, לא הלכה.
|
||||
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים/מספרים קונקרטיים) — חלץ את **העיקרון המופשט** בלבד, לא את יישומו על עובדות התיק.
|
||||
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
||||
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
|
||||
- הצהרות על דין קיים שאינן מיושמות בהכרעה, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||
|
||||
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
||||
|
||||
@@ -121,10 +124,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
|
||||
]
|
||||
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה.
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת ו**שלמה** מהקלט (משפט שלם, לא חתוך באמצע). אם אין ציטוט מתאים — אל תמציא הלכה.
|
||||
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
|
||||
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח.
|
||||
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט.
|
||||
3. **לא לפצל יתר על המידה — קריטי** — כל הלכה = שאלה משפטית מובחנת אחת. אם כמה סעיפים מבטאים פנים שונים של אותה שאלה משפטית — אחד אותם לכלל אחד (בחר את הניסוח הכללי/המחייב ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||
4. **שפה והפשטה** — rule_statement בעברית משפטית מקצועית בגוף שלישי, כעיקרון בר-הכללה לתיקים עתידיים — **לא** צמצום מילולי של הציטוט ולא קביעה התלויה בעובדות התיק.
|
||||
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
|
||||
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
|
||||
"""
|
||||
@@ -143,8 +146,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
||||
|
||||
**אין לחלץ:**
|
||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
||||
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל.
|
||||
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
|
||||
- סוגיה שהפנל לא הכריע בה ("אין צורך להכריע", "מבלי לקבוע מסמרות", "למעלה מן הצורך").
|
||||
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
|
||||
- אמרות אגב חסרות חשיבות.
|
||||
|
||||
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
||||
@@ -170,9 +174,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
|
||||
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אם אין מה לחלץ — החזר [].
|
||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
|
||||
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
||||
4. **לא לפצל יתר על המידה** — שני סעיפים זהים מבחינה רעיונית = פריט אחד.
|
||||
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
|
||||
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
||||
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
||||
7. **confidence** — 0..1. דייק.
|
||||
@@ -496,6 +500,16 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
|
||||
coerced["quote_verified"] = _verify_quote(
|
||||
coerced["supporting_quote"], full_text,
|
||||
)
|
||||
# Strict-rubric quality gate (docs/halacha-strict-rubric.md):
|
||||
# flags block auto-approval (route to pending_review); a court
|
||||
# non-decision is re-typed obiter so it never reads as a holding.
|
||||
flags = halacha_quality.compute_quality_flags(
|
||||
coerced["rule_statement"], coerced["supporting_quote"],
|
||||
coerced["reasoning_summary"], coerced["quote_verified"],
|
||||
)
|
||||
coerced["quality_flags"] = flags
|
||||
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
|
||||
coerced["rule_type"] = "obiter"
|
||||
cleaned.append(coerced)
|
||||
if cleaned:
|
||||
embed_inputs = [
|
||||
|
||||
158
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
158
mcp-server/src/legal_mcp/services/halacha_quality.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Pure quality validators + dedup helpers for halacha extraction.
|
||||
|
||||
These encode the "strict rules" rubric (docs/halacha-strict-rubric.md) that
|
||||
drove the 2026-06-03 corpus cleanup (1454→534), so that future extraction
|
||||
comes out clean instead of accumulating duplicates, obiter dicta, truncated
|
||||
quotes and thin restatements that clog the review queue.
|
||||
|
||||
Everything here is a PURE function (no DB, no LLM) so it is fully unit-tested.
|
||||
The DB-touching dedup-on-insert (uses these helpers) lives in
|
||||
``db.store_halachot_for_chunk``.
|
||||
|
||||
Flags produced by :func:`compute_quality_flags` BLOCK auto-approval (the item
|
||||
routes to ``pending_review`` regardless of confidence) but never delete — the
|
||||
chair still sees flagged items, just out of the auto-approved stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── Hebrew text normalization (shared with the extractor's quote check) ──
|
||||
|
||||
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Collapse whitespace and unify Hebrew quote-mark variants for matching.
|
||||
|
||||
Kept dependency-free (the extractor previously routed through
|
||||
``proofreader._fix_hebrew_quotes``; here we inline a quote-class collapse so
|
||||
this module stays pure and importable from anywhere).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
# Unify the half-dozen quote/gershayim variants to a single ASCII quote.
|
||||
unified = re.sub(f"[{re.escape(_HEB_QUOTE_VARIANTS)}]", '"', text)
|
||||
return re.sub(r"\s+", " ", unified).strip()
|
||||
|
||||
|
||||
# ── Non-decision / obiter detection (Wambaugh: the court did not decide) ──
|
||||
#
|
||||
# High-precision markers only. Phrases like "לכאורה" / "ניתן להניח" alone are
|
||||
# too common to flag reliably, so we require the explicit "declined to rule"
|
||||
# formulations the rubric calibration confirmed on שפר (idx 32: "איני רואה
|
||||
# לקבוע מסמרות") and on 8027-25 (idx 18-19: "אין צורך להכריע").
|
||||
|
||||
NON_DECISION_MARKERS = (
|
||||
"אין צורך להכריע",
|
||||
"איני נדרש להכריע",
|
||||
"איננו נדרשים להכריע",
|
||||
"אין אנו נדרשים להכריע",
|
||||
"מתייתר הצורך להכריע",
|
||||
"אין צורך לקבוע מסמרות",
|
||||
"מבלי לקבוע מסמרות",
|
||||
"איני רואה לקבוע מסמרות",
|
||||
"איננו רואים לקבוע מסמרות",
|
||||
"אין לקבוע מסמרות",
|
||||
"אין מקום לקבוע מסמרות",
|
||||
"לא ראינו לקבוע מסמרות",
|
||||
"למעלה מן הצורך",
|
||||
"למעלה מהצורך",
|
||||
"למעלה מן הדרוש",
|
||||
"מעבר לנדרש",
|
||||
"אגב אורחא",
|
||||
"אגב אורחה",
|
||||
)
|
||||
|
||||
|
||||
def detect_non_decision(*texts: str) -> str | None:
|
||||
"""Return the first non-decision marker found across ``texts`` (or None).
|
||||
|
||||
Scans rule_statement + reasoning_summary + supporting_quote — the court's
|
||||
own hedge usually sits in the quote/reasoning, not the abstracted rule.
|
||||
"""
|
||||
joined = normalize_text(" ".join(t for t in texts if t))
|
||||
for marker in NON_DECISION_MARKERS:
|
||||
if marker in joined:
|
||||
return marker
|
||||
return None
|
||||
|
||||
|
||||
# ── Truncated / incomplete supporting-quote detection ──
|
||||
#
|
||||
# Conservative: only flag a CLEAR mid-word cut — the quote's last whitespace-
|
||||
# delimited token is a single Hebrew letter (a dangling construct/prefix such
|
||||
# as the "...על ה" in 8099-02-17 idx 6). A complete clause ends in a full word,
|
||||
# so this does not fire on quotes that merely lack a trailing period (the
|
||||
# calibration showed ~1/3 of valid quotes drop the final period legitimately).
|
||||
|
||||
_HEB_LETTER = "א-ת"
|
||||
|
||||
|
||||
def is_quote_truncated(quote: str) -> bool:
|
||||
norm = normalize_text(quote)
|
||||
if not norm:
|
||||
return True
|
||||
tokens = norm.split(" ")
|
||||
last = tokens[-1].strip('".,;:)]')
|
||||
# dangling single Hebrew letter at the end == cut mid-word
|
||||
if len(last) == 1 and re.match(f"[{_HEB_LETTER}]", last):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Thin restatement: rule_statement adds nothing over the quote ──
|
||||
#
|
||||
# Flag when the rule is essentially a copy of the quote: high token overlap AND
|
||||
# the rule is no longer than the quote. A genuine halacha ABSTRACTS the rule, so
|
||||
# it introduces wording the verbatim quote lacks and/or generalizes (longer or
|
||||
# differently phrased).
|
||||
|
||||
_THIN_OVERLAP = 0.85
|
||||
_THIN_LEN_RATIO = 1.10
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
norm = normalize_text(text)
|
||||
return {t for t in re.split(r"[^א-ת0-9]+", norm) if len(t) > 1}
|
||||
|
||||
|
||||
def is_thin_restatement(rule_statement: str, supporting_quote: str) -> bool:
|
||||
rule_t = _tokens(rule_statement)
|
||||
quote_t = _tokens(supporting_quote)
|
||||
if not rule_t or not quote_t:
|
||||
return False
|
||||
overlap = len(rule_t & quote_t) / len(rule_t)
|
||||
len_ratio = len(normalize_text(rule_statement)) / max(1, len(normalize_text(supporting_quote)))
|
||||
return overlap >= _THIN_OVERLAP and len_ratio <= _THIN_LEN_RATIO
|
||||
|
||||
|
||||
# ── Aggregate ──
|
||||
|
||||
FLAG_NON_DECISION = "non_decision"
|
||||
FLAG_TRUNCATED_QUOTE = "truncated_quote"
|
||||
FLAG_THIN_RESTATEMENT = "thin_restatement"
|
||||
FLAG_QUOTE_UNVERIFIED = "quote_unverified"
|
||||
|
||||
|
||||
def compute_quality_flags(
|
||||
rule_statement: str,
|
||||
supporting_quote: str,
|
||||
reasoning_summary: str = "",
|
||||
quote_verified: bool = True,
|
||||
) -> list[str]:
|
||||
"""Return the list of quality flags for one halacha (empty == clean).
|
||||
|
||||
Any non-empty result blocks auto-approval (routes to pending_review).
|
||||
"""
|
||||
flags: list[str] = []
|
||||
if detect_non_decision(rule_statement, reasoning_summary, supporting_quote):
|
||||
flags.append(FLAG_NON_DECISION)
|
||||
if is_quote_truncated(supporting_quote):
|
||||
flags.append(FLAG_TRUNCATED_QUOTE)
|
||||
if is_thin_restatement(rule_statement, supporting_quote):
|
||||
flags.append(FLAG_THIN_RESTATEMENT)
|
||||
if not quote_verified:
|
||||
flags.append(FLAG_QUOTE_UNVERIFIED)
|
||||
return flags
|
||||
93
mcp-server/tests/test_halacha_quality.py
Normal file
93
mcp-server/tests/test_halacha_quality.py
Normal file
@@ -0,0 +1,93 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user