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:
2026-06-03 12:30:38 +00:00
parent b0ec24a9d5
commit ca959d4a9c
6 changed files with 386 additions and 18 deletions

View File

@@ -0,0 +1,37 @@
# רובריקת "הכללים המחמירים" לחילוץ הלכות — להחלה על הלכות קיימות
אתה בודק רשימת הלכות שחולצו מפסק דין **אחד**, ומחליט לכל אחת: לשמור או לחתוך (ובאיזו עילה).
המטרה: שיישארו רק **עקרונות משפטיים אמיתיים, מובחנים, בני-הכללה ובני-הסתמכות** — לא ציטוטים, לא אמרות-אגב, לא יישומים ספציפיים-לתיק, לא כפילויות.
## עילות חיתוך (verdict)
1. **cut_duplicate** — ההלכה מבטאת את **אותו עיקרון משפטי** של הלכה אחרת באותו פסק, גם אם בניסוח שונה / ציטוט שונה.
- קבץ את כל המופעים של אותו עיקרון. שמור **נציג אחד** בלבד; סמן את השאר cut_duplicate.
- בחירת הנציג (canonical): עדיפות rule_type (binding > interpretive > procedural > obiter) → confidence גבוה → quote_verified=true → הניסוח המלא/הברור ביותר.
- דווח `cluster_canonical_index` = ה-halacha_index של הנציג שנשמר.
2. **cut_obiter** — אמרת-אגב שהערכאה **לא הכריעה בה**. סימנים: "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "לא ראינו לקבוע", "ניתן/יש להניח ... אך", "למעלה מן הצורך", "אגב אורחא", או הסתמכות על "לכאורה" כבסיס.
- מבחן Wambaugh: אם שלילת הכלל **לא** הייתה משנה את תוצאת הפסק → obiter.
3. **cut_application** — קביעה שתלויה ב**עובדות התיק הספציפי** ואינה בת-הכללה: שמות צדדים ("המשיבים", "המערערים", שם משפחה), "במקרה דנן/שבפנינו", סכומים/תאריכים/מספרים ספציפיים למחלוקת, יישום הכלל על המבנה/ההיתר הקונקרטי. זהו "ציטוט שטוב שיש" — המחשה, לא הלכה.
4. **cut_thin** — restatement דק: ה-rule_statement כמעט מעתיק את supporting_quote בלי הפשטה; **או** הכלל מנוסח כרקע/מוסכמה ("אין חולק כי...") ולא כהכרעה.
5. **cut_quote** — ה-supporting_quote קטוע באמצע משפט / חסר, או quote_verified=false וההלכה נשענת עליו.
6. **keep** — עיקרון משפטי אמיתי, מובחן, בר-הכללה, שהוכרע, עם ציטוט תומך שלם.
## כללי הכרעה — רמה אגרסיבית
המטרה: להשאיר רק את **גרעין העקרונות המובחנים**. עדיף תמציתי ומדויק על פני שלם-ומנופח.
- **cut_application אסרטיבי:** כל קביעה שנשענת על עובדות/צדדים/סכומים ספציפיים לתיק → cut_application, גם אם משתמעת ממנה הלכה. ההלכה המופשטת כבר אמורה להופיע בנפרד; היישום עצמו מיותר.
- **מיזוג facets חופפים (cut_duplicate מורחב):** אם שתי הלכות עונות על **אותה שאלה משפטית** גם אם מזווית/פן שונה — מזג לנציג הכללי/binding ביותר. דוגמאות למיזוג: עקרונות-משנה בתוך אותו נושא (סמכות ועדת הערר, מתחם שיקול-הדעת התכנוני, מיצוי הליכים, בטלות יחסית).
- **גבול המיזוג (שמור):** אל תמזג הלכות שעונות על **שאלות משפטיות שונות** (למשל "מועד 30 יום להגשת ערר" ≠ "עקרון מיצוי ההליכים"; "פרשנות תיקון 43" ≠ "סמכות לפי סיווג הבקשה"). מזג פנים-של-אותה-שאלה, לא בין-שאלות.
- **dedup מושגי הוא העיקרי:** רוב החיתוך מ-cut_duplicate. שים לב לעקרונות שחוזרים 3-5 פעמים בניסוחים שונים וגם ל-facets שחוזרים סביב אותו נושא.
- בספק בין keep ל-cut בקטגוריה מאבדת-מידע: ברמה זו **נטה לחתוך** (אך לעולם לא למזג שאלות-משפטיות שונות).
## פלט (JSON בלבד)
מערך, פריט לכל הלכה:
```json
[{"halacha_index": <int>, "verdict": "keep|cut_duplicate|cut_obiter|cut_application|cut_thin|cut_quote", "cluster_canonical_index": <int או null>, "reason": "<משפט אחד>"}]
```

View File

@@ -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", "")

View File

@@ -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,

View File

@@ -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 = [

View 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

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