From ca959d4a9c6ce46539ca3ce1c57e4990bc6883b9 Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 3 Jun 2026 12:30:38 +0000 Subject: [PATCH] feat(halacha): strict-rubric quality gate + dedup-on-insert (#81,#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/halacha-strict-rubric.md | 37 ++++ mcp-server/src/legal_mcp/config.py | 10 ++ mcp-server/src/legal_mcp/services/db.py | 72 +++++++- .../legal_mcp/services/halacha_extractor.py | 34 ++-- .../src/legal_mcp/services/halacha_quality.py | 158 ++++++++++++++++++ mcp-server/tests/test_halacha_quality.py | 93 +++++++++++ 6 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 docs/halacha-strict-rubric.md create mode 100644 mcp-server/src/legal_mcp/services/halacha_quality.py create mode 100644 mcp-server/tests/test_halacha_quality.py diff --git a/docs/halacha-strict-rubric.md b/docs/halacha-strict-rubric.md new file mode 100644 index 0000000..693bf24 --- /dev/null +++ b/docs/halacha-strict-rubric.md @@ -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": , "verdict": "keep|cut_duplicate|cut_obiter|cut_application|cut_thin|cut_quote", "cluster_canonical_index": , "reason": "<משפט אחד>"}] +``` diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index 4ebf0be..4951c15 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -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", "") diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index c1c51c5..05cceed 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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, diff --git a/mcp-server/src/legal_mcp/services/halacha_extractor.py b/mcp-server/src/legal_mcp/services/halacha_extractor.py index b7f8244..9323dde 100644 --- a/mcp-server/src/legal_mcp/services/halacha_extractor.py +++ b/mcp-server/src/legal_mcp/services/halacha_extractor.py @@ -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 = [ diff --git a/mcp-server/src/legal_mcp/services/halacha_quality.py b/mcp-server/src/legal_mcp/services/halacha_quality.py new file mode 100644 index 0000000..9341ba2 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/halacha_quality.py @@ -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 diff --git a/mcp-server/tests/test_halacha_quality.py b/mcp-server/tests/test_halacha_quality.py new file mode 100644 index 0000000..9cd814e --- /dev/null +++ b/mcp-server/tests/test_halacha_quality.py @@ -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")