diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 427ef40..aace2ae 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -155,6 +155,14 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified **אכיפה:** CHECK על enums; FK על `cited_precedents`/`decision_paragraphs.citations`; איחוד `case_precedents`↔`case_law`. **הפרה ידועה:** 20+ enums כ-TEXT חופשי; `legal_arguments.cited_precedents TEXT[]` ללא-FK (הזיות-LLM נבלעות); `case_precedents` מול `case_law` מקבילות ([gap-audit GAP-40/42/43](gap-audit.md)). +### INV-DM7: סיווג-הלכה — סמכות (נגזרת) ⊥ תפקיד-כלל (מסווג). שני צירים, לא enum אחד +**כלל:** ל-`halachot` שני צירי-סיווג **אורתוגונליים** שאסור לערבב בשדה אחד: +- **סמכות (`authority`) — נגזרת בלבד, לא מאוחסנת, לא מנוחשת ע"י LLM.** `binding` (מקור מחייב את הוועדה: עליון/מנהלי) מול `persuasive` (מקור משכנע: ועדת-ערר אחרת). נגזרת דטרמיניסטית מ-`case_law.precedent_level` (`עליון`/`מנהלי`→binding; `ועדת_ערר_מחוזית`→persuasive). מקור-אמת יחיד — מחושבת בקריאה, אין עמודה כפולה ([G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)). +- **תפקיד-כלל (`rule_type`/rule_role) — מסווג ע"י ה-LLM.** `holding` (עיקרון מהותי הכרחי להכרעה — ratio/Wambaugh) · `interpretive` (פרשנות חוק/מונח/תכנית) · `procedural` (סדר-דין: סמכות/מועדים/נטל) · `application` (החלה תלוית-עובדות — לרוב לא-הלכה) · `obiter` (אמרת-אגב). **`binding`/`persuasive` אינם ערכי תפקיד** — הם סמכות-מקור. +**הנדסי.** מופע של [G1](00-constitution.md#inv-g1-נרמול-במקור-לא-תיקון-בקריאה) (נרמול במקור: המחלץ מסווג תפקיד, לא ממציא סמכות נגזירה) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים). +**מקורות:** OASIS LegalRuleML v1.0 (`appliesAuthority`/`Strength` כ-metadata אורתוגונלי, נפרד מלוגיקת-הכלל) · SemEval-2023 Task 6 LegalEval (rhetorical-roles לפי תפקיד, סמכות נשמרת בנפרד) · Bluebook signals (משקל-סמכות = ציר נפרד מהפרופוזיציה) | סטטוס: verified (≥3 מקורות). +**ההפרה שתוקנה:** `halacha_extractor` סיווג `rule_type` לפי bindingness-של-המקור (`_coerce_halacha(is_binding)`, ברירת-מחדל `binding`/`persuasive`, guard binding→persuasive) — כלומר חישב **סמכות** במסווה של **תפקיד**. אומת אמפירית על מדגם-הזהב: `binding` שימש 19/19 פסקים חיצוניים ו-0 ועדות; `persuasive` 13/13 ועדות ו-0 חיצוניים → סיווג-לפי-מקור, התאמה לתיוג-אנושי 58% בלבד. התיקון מעביר סמכות לציר-נגזר ומשחרר את ה-LLM לסווג תפקיד נטו. + --- ## 4. מצב קיים מול יעד — audit-findings diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index aeebf44..6b908e5 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -664,8 +664,10 @@ CREATE TABLE IF NOT EXISTS halachot ( case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE, halacha_index INTEGER NOT NULL, rule_statement TEXT NOT NULL, - rule_type TEXT DEFAULT 'binding', - -- binding | interpretive | procedural | obiter + rule_type TEXT DEFAULT 'interpretive', + -- rule ROLE only (INV-DM7): holding | interpretive | procedural | + -- application | obiter. authority (binding/persuasive) is DERIVED + -- from case_law.precedent_level, never stored here. reasoning_summary TEXT DEFAULT '', supporting_quote TEXT NOT NULL, page_reference TEXT DEFAULT '', @@ -4095,7 +4097,7 @@ async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int: case_law_id, i, h["rule_statement"], - h.get("rule_type", "binding"), + h.get("rule_type", "interpretive"), h.get("reasoning_summary", ""), h["supporting_quote"], h.get("page_reference", ""), @@ -4236,7 +4238,7 @@ async def store_halachot_for_chunk( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $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.get("rule_type", "interpretive"), 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), @@ -4342,6 +4344,8 @@ async def list_halachot( d = dict(r) if d.get("decision_date") is not None: d["decision_date"] = d["decision_date"].isoformat() + # authority is DERIVED from the source, never stored (INV-DM7) + d["authority"] = halacha_quality.derive_authority(d.get("precedent_level")) out.append(d) if cluster and out: await _annotate_clusters(pool, out) @@ -4764,7 +4768,7 @@ async def goldset_list(batch: str = "default") -> list[dict]: " g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, " " h.rule_statement, h.supporting_quote, h.reasoning_summary, " " h.rule_type, h.confidence, h.quality_flags, h.review_status, " - " cl.case_number, cl.case_name, cl.source_type " + " cl.case_number, cl.case_name, cl.source_type, cl.precedent_level " "FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id " "LEFT JOIN case_law cl ON cl.id = h.case_law_id " "WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch, @@ -4778,6 +4782,8 @@ async def goldset_list(batch: str = "default") -> list[dict]: d["ai_generated_at"] = d["ai_generated_at"].isoformat() if d.get("confidence") is not None: d["confidence"] = float(d["confidence"]) + # authority is DERIVED from the source, never stored (INV-DM7) + d["authority"] = halacha_quality.derive_authority(d.get("precedent_level")) out.append(d) return out @@ -4835,7 +4841,7 @@ async def goldset_score(batch: str = "default") -> dict: for r in labeled: rule = r.get("rule_statement") or "" quote = r.get("supporting_quote") or "" - rtype = r.get("rule_type") or "binding" + rtype = r.get("rule_type") or "interpretive" qc = r["quote_complete"] if r["quote_complete"] is not None else True truly_bad = r["is_holding"] is False flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype) @@ -5033,6 +5039,8 @@ async def search_precedent_library_semantic( _conf = float(d.get("confidence") or 0.0) d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0) d["type"] = "halacha" + # authority is DERIVED from the source, never stored (INV-DM7) + d["authority"] = halacha_quality.derive_authority(d.get("precedent_level")) results.append(d) rows = await pool.fetch(chunk_sql, *c_params) diff --git a/mcp-server/src/legal_mcp/services/halacha_extractor.py b/mcp-server/src/legal_mcp/services/halacha_extractor.py index af4c22e..d8dd872 100644 --- a/mcp-server/src/legal_mcp/services/halacha_extractor.py +++ b/mcp-server/src/legal_mcp/services/halacha_extractor.py @@ -76,8 +76,12 @@ EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion") # wants to be able to cite "another committee reached the same conclusion" # even though it is not binding. # -# The schema's rule_type field accepts six values: -# binding | interpretive | procedural | obiter | application | persuasive +# The prompt branches on is_binding only to choose the EXTRACTION STRATEGY +# (what to pull, how to phrase) — NOT the rule_type. rule_type is the rule +# ROLE and uses the SAME five values for both sources (INV-DM7): +# holding | interpretive | procedural | application | obiter +# The authority axis (binding/persuasive) is derived from the source, never +# a rule_type value — so the model never classifies it. HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי). @@ -101,10 +105,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד. -## סוגי הלכה (rule_type) -- binding — הלכה מחייבת שהוחלה על התיק. -- interpretive — פרשנות סעיף חוק/תכנית שאומצה. -- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה). +## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור +**אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**: +- holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה). +- interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה. +- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל). +- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה). - obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך). ## פלט נדרש @@ -112,7 +118,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה [ { "rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.", - "rule_type": "binding", + "rule_type": "holding", "reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).", "supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.", "page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.", @@ -139,11 +145,11 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת. -**יש לחלץ:** +**יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית): - **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה. - **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ. - **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך. -- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת. +- **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות. **אין לחלץ:** - ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד. @@ -175,7 +181,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח ## כללי איכות 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה. 2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר []. -3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא. +3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית). 4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים. 5. **שפה** — עברית משפטית מקצועית, גוף שלישי. 6. **subject_tags** — 2-5 תגיות בעברית, snake_case. @@ -184,10 +190,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח _VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"} +# rule_type holds the rule ROLE only — what KIND of statement it is (INV-DM7). +# The authority axis (binding/persuasive) is DERIVED from the source, never a +# rule_type value: see halacha_quality.derive_authority. _VALID_RULE_TYPES = { - "binding", "interpretive", "procedural", "obiter", - "application", "persuasive", + "holding", "interpretive", "procedural", "application", "obiter", } +# Legacy authority-as-role values → fold to the nearest genuine role. Kept so +# old LLM outputs (and pre-split rows re-fed) coerce safely. +_LEGACY_RULE_TYPE_FOLD = {"binding": "holding", "persuasive": "interpretive"} def _normalize_for_comparison(text: str) -> str: @@ -227,13 +238,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool: return False -def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None: +def _coerce_halacha(raw: dict) -> dict | None: """Validate and normalize one LLM-returned halacha dict. - Returns ``None`` if the entry is missing required fields. ``is_binding`` - only affects the default rule_type when the LLM returned an unknown - value — for binding sources we default to ``binding``, otherwise to - ``persuasive`` (never pretend an appeals committee created halacha). + Returns ``None`` if the entry is missing required fields. ``rule_type`` is + the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's + bindingness (that was the source-conflation this split removed). Legacy + authority values fold to the nearest role; unknown defaults to + ``interpretive`` (the most common role). """ if not isinstance(raw, dict): return None @@ -242,13 +254,10 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None: if not rule_statement or not supporting_quote: return None - default_rule_type = "binding" if is_binding else "persuasive" - rule_type = (raw.get("rule_type") or default_rule_type).strip().lower() + rule_type = (raw.get("rule_type") or "").strip().lower() + rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type) if rule_type not in _VALID_RULE_TYPES: - rule_type = default_rule_type - # Guard: don't let a non-binding source produce 'binding' rule_type - if not is_binding and rule_type == "binding": - rule_type = "persuasive" + rule_type = "interpretive" practice_areas_raw = raw.get("practice_areas") or [] if isinstance(practice_areas_raw, str): @@ -580,7 +589,7 @@ async def _extract_impl(case_law_id: UUID, force: bool = False, return cleaned: list[dict] = [] for raw in items: - coerced = _coerce_halacha(raw, is_binding=is_binding) + coerced = _coerce_halacha(raw) if coerced is None: continue coerced["quote_verified"] = _verify_quote( @@ -597,10 +606,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False, coerced["quality_flags"] = flags if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter": coerced["rule_type"] = "obiter" - # #81.4 — a binding-labeled rule that reads as a case-application is + # #81.4 — a holding-labeled rule that reads as a case-application is # re-typed application (it carries FLAG_APPLICATION either way). elif (halacha_quality.FLAG_APPLICATION in flags - and coerced["rule_type"] == "binding"): + and coerced["rule_type"] == "holding"): coerced["rule_type"] = "application" cleaned.append(coerced) # #81.3 NLI entailment — one batched judge call per chunk (fail-open). diff --git a/mcp-server/src/legal_mcp/services/halacha_quality.py b/mcp-server/src/legal_mcp/services/halacha_quality.py index e88eda1..5d15c17 100644 --- a/mcp-server/src/legal_mcp/services/halacha_quality.py +++ b/mcp-server/src/legal_mcp/services/halacha_quality.py @@ -18,6 +18,37 @@ from __future__ import annotations import re +# ── Authority axis — DERIVED from the source, never LLM-classified (INV-DM7) ── +# +# A halacha's *authority* (binding vs persuasive) is a property of WHERE it came +# from, not of the rule's content. It is therefore derived deterministically +# from ``case_law.precedent_level`` and never stored on ``halachot`` or guessed +# by the extractor — keeping it orthogonal to ``rule_type`` (the rule ROLE). +# Higher courts (עליון/מנהלי) bind the appeals committee; another committee is +# only persuasive. See docs/spec/02-data-model.md INV-DM7. + +AUTHORITY_BINDING = "binding" +AUTHORITY_PERSUASIVE = "persuasive" + +_BINDING_LEVELS = {"עליון", "מנהלי"} +_PERSUASIVE_LEVELS = {"ועדת_ערר_מחוזית"} + + +def derive_authority(precedent_level: str | None) -> str | None: + """Map a source's precedent_level to its authority over the committee. + + Returns ``"binding"`` for higher courts (עליון/מנהלי), ``"persuasive"`` for + another appeals committee (ועדת_ערר_מחוזית), or ``None`` when the level is + unknown/empty (never guesses). Pure — the single source of truth for the + authority axis (INV-DM7). + """ + level = (precedent_level or "").strip() + if level in _BINDING_LEVELS: + return AUTHORITY_BINDING + if level in _PERSUASIVE_LEVELS: + return AUTHORITY_PERSUASIVE + return None + # ── Hebrew text normalization (shared with the extractor's quote check) ── _HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″" @@ -337,7 +368,7 @@ def compute_quality_flags( supporting_quote: str, reasoning_summary: str = "", quote_verified: bool = True, - rule_type: str = "binding", + rule_type: str = "interpretive", ) -> list[str]: """Return the list of quality flags for one halacha (empty == clean). diff --git a/mcp-server/tests/test_halacha_coerce.py b/mcp-server/tests/test_halacha_coerce.py new file mode 100644 index 0000000..d4fe509 --- /dev/null +++ b/mcp-server/tests/test_halacha_coerce.py @@ -0,0 +1,46 @@ +"""rule_type coercion after the authority/role split (INV-DM7). + +The extractor's rule_type holds the rule ROLE only — it is never defaulted from +the source's bindingness. Legacy authority values fold to the nearest role. +""" +from legal_mcp.services.halacha_extractor import ( + _LEGACY_RULE_TYPE_FOLD, + _VALID_RULE_TYPES, + _coerce_halacha, +) + +_BASE = {"rule_statement": "כלל כלשהו", "supporting_quote": "ציטוט תומך כלשהו"} + + +def _rt(rule_type): + return _coerce_halacha({**_BASE, "rule_type": rule_type})["rule_type"] + + +def test_valid_roles_are_the_five_roles_only(): + assert _VALID_RULE_TYPES == { + "holding", "interpretive", "procedural", "application", "obiter", + } + assert "binding" not in _VALID_RULE_TYPES + assert "persuasive" not in _VALID_RULE_TYPES + + +def test_legacy_authority_values_fold_to_a_role(): + assert _rt("binding") == "holding" + assert _rt("persuasive") == "interpretive" + assert _LEGACY_RULE_TYPE_FOLD == {"binding": "holding", "persuasive": "interpretive"} + + +def test_genuine_roles_pass_through(): + for role in ("holding", "interpretive", "procedural", "application", "obiter"): + assert _rt(role) == role + + +def test_unknown_or_missing_defaults_to_interpretive(): + assert _rt("nonsense") == "interpretive" + assert _coerce_halacha(_BASE)["rule_type"] == "interpretive" + + +def test_coerce_rejects_rows_missing_required_fields(): + assert _coerce_halacha({"rule_statement": "x"}) is None + assert _coerce_halacha({"supporting_quote": "y"}) is None + assert _coerce_halacha("not a dict") is None diff --git a/mcp-server/tests/test_halacha_quality.py b/mcp-server/tests/test_halacha_quality.py index a84a813..bf6c2ac 100644 --- a/mcp-server/tests/test_halacha_quality.py +++ b/mcp-server/tests/test_halacha_quality.py @@ -211,23 +211,40 @@ def test_application_flag_from_rule_type(): assert hq.FLAG_APPLICATION in flags -def test_application_flag_from_deixis_even_if_binding(): +def test_application_flag_from_deixis_even_if_holding(): flags = hq.compute_quality_flags( "במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר", - rule_type="binding", + rule_type="holding", ) assert hq.FLAG_APPLICATION in flags -def test_clean_binding_rule_has_no_flags(): +def test_clean_holding_rule_has_no_flags(): flags = hq.compute_quality_flags( "ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית", "הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.", - rule_type="binding", + rule_type="holding", ) assert flags == [] +# ── INV-DM7: authority is DERIVED from the source, never a rule_type value ── + +def test_derive_authority_binding_for_higher_courts(): + assert hq.derive_authority("עליון") == "binding" + assert hq.derive_authority("מנהלי") == "binding" + + +def test_derive_authority_persuasive_for_committee(): + assert hq.derive_authority("ועדת_ערר_מחוזית") == "persuasive" + + +def test_derive_authority_none_for_unknown_or_empty(): + assert hq.derive_authority("") is None + assert hq.derive_authority(None) is None + assert hq.derive_authority("משהו אחר") is None + + # ── #82.3 lexical near-duplicate signal ── def test_jaccard_high_for_reworded_same_rule(): diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 2f39b41..32e2f91 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -41,6 +41,7 @@ | `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case ` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) | | `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in ` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score | | `goldset_ai_recommend.py` | python | **#81.7 QA** — מייצר **חוות-דעת-AI שנייה** (claude מקומי, אפס עלות) לכל פריט ב-`halacha_goldset`: `is_holding`+`type`+נימוק, נשמר ב-`ai_*` ומוצג בדף לצד התיוג האנושי לזיהוי אי-הסכמות. **עצמאי** מהוולידטורים שנמדדים (אין מעגליות) ו**לא** מוחל אוטומטית. `--force` (חידוש)/`--limit N`. **חובה מקומי** (claude_session). | ידני — לאחר יצירת/הרחבת batch | +| `halacha_rule_role_backfill.py` | python | **INV-DM7** — backfill חד-פעמי: מסווג-מחדש את ההלכות הישנות (`rule_type IN ('binding','persuasive')` — ערכי-סמכות שנשמרו במסווה תפקיד לפני פיצול הצירים) לאחד מחמשת **תפקידי-הכלל** (holding/interpretive/procedural/application/obiter) דרך claude_session המקומי (אפס עלות). **לא נוגע בסמכות** (נגזרת מ-`precedent_level`). `--apply` (ברירת-מחדל dry-run) / `--limit N` / `--concurrency`. כותב backup CSV ל-`data/audit/` תחילה. fail-safe (פריט שנכשל → נשמר ערך ישן). **חובה מקומי** (claude_session). | ידני חד-פעמי אחרי deploy של פיצול-הסמכות | | `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור | | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest `. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | | `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) | diff --git a/scripts/goldset_ai_recommend.py b/scripts/goldset_ai_recommend.py index ab36fa8..8eca496 100644 --- a/scripts/goldset_ai_recommend.py +++ b/scripts/goldset_ai_recommend.py @@ -25,20 +25,21 @@ from uuid import UUID from legal_mcp.services import claude_session, db -VALID_TYPES = {"binding", "interpretive", "obiter", "application", "procedural", "persuasive"} +VALID_TYPES = {"holding", "interpretive", "procedural", "application", "obiter"} SYSTEM = ( "אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. " "לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n" "1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום " "תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n" - "2) type — הסוג הנכון: 'binding' (עיקרון הכרחי להכרעה), 'interpretive' (פרשנות " - "חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), 'persuasive' " - "(אסמכתה לא-מחייבת), 'application' (החלה על עובדות התיק — לרוב לא-הלכה), " - "'obiter' (אמרת-אגב שלא הוכרעה — לא-הלכה).\n" - "עקביות: is_holding=true → binding/interpretive/procedural/persuasive; " + "2) type — **סוג הכלל בלבד** (אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית " + "מזהות הערכאה): 'holding' (עיקרון מהותי שהיה הכרחי להכרעה — ratio), 'interpretive' " + "(פרשנות חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), " + "'application' (החלה על עובדות התיק — לרוב לא-הלכה), 'obiter' (אמרת-אגב שלא " + "הוכרעה — לא-הלכה).\n" + "עקביות: is_holding=true → holding/interpretive/procedural; " "is_holding=false → application/obiter.\n" - 'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהשישה>", ' + 'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהחמישה>", ' '"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.' ) diff --git a/scripts/halacha_rule_role_backfill.py b/scripts/halacha_rule_role_backfill.py new file mode 100644 index 0000000..0b30294 --- /dev/null +++ b/scripts/halacha_rule_role_backfill.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""One-time backfill: recover the rule ROLE for pre-split halachot (INV-DM7). + +Before the authority/role split, the extractor stored ``rule_type='binding'`` +for higher-court sources and ``'persuasive'`` for committee sources — i.e. it +recorded the source's AUTHORITY in the role field. Those 276 rows therefore have +NO genuine role. This script re-classifies each into one of the five real roles +(holding/interpretive/procedural/application/obiter) using the same local +claude_session judge the gold-set trusts (zero API cost), and writes it back to +``halachot.rule_type``. + +authority is NOT touched — it is derived from ``case_law.precedent_level`` at +read time and was never stored. + + cd ~/legal-ai/mcp-server + .venv/bin/python ../scripts/halacha_rule_role_backfill.py --limit 5 # smoke (dry) + .venv/bin/python ../scripts/halacha_rule_role_backfill.py --apply # full backfill + +Local-only (claude_session needs the local CLI, not the container). +""" +from __future__ import annotations + +import argparse +import asyncio +import csv +import sys +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID + +from legal_mcp.services import claude_session, db + +REPO_ROOT = Path(__file__).resolve().parent.parent +AUDIT_DIR = REPO_ROOT / "data" / "audit" + +VALID_ROLES = {"holding", "interpretive", "procedural", "application", "obiter"} + +SYSTEM = ( + "אתה משפטן בכיר המסווג 'הלכות' שחולצו מפסיקה לפי **סוג הכלל** בלבד " + "(אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה). " + "בחר ערך אחד מתוך:\n" + "- holding — עיקרון מהותי שהיה הכרחי להכרעה (ratio; מבחן Wambaugh).\n" + "- interpretive — פרשנות הוראת-חוק/מונח/תכנית.\n" + "- procedural — סדר-דין: סמכות/מועדים/זכות-עמידה/מיצוי/נטל.\n" + "- application — החלה תלוית-עובדות על נסיבות התיק (לרוב לא-הלכה בת-הכללה).\n" + "- obiter — אמרת-אגב שלא הוכרעה.\n" + 'החזר JSON בלבד: {"role": "<אחד מהחמישה>"}. ללא markdown, ללא הסבר.' +) + + +def _prompt(row: dict) -> str: + return ( + f"מקור: {row.get('case_number') or ''} " + f"(precedent_level={row.get('precedent_level') or ''}).\n" + f"סיווג ישן (סמכות, להתעלם): {row.get('rule_type')}.\n\n" + f"ניסוח הכלל:\n{row.get('rule_statement') or ''}\n\n" + f"היגיון:\n{row.get('reasoning_summary') or ''}\n\n" + f"ציטוט תומך:\n{row.get('supporting_quote') or ''}" + ) + + +async def _classify(row: dict) -> str | None: + """Return the role for one row, or None on failure (caller keeps old value).""" + try: + raw = await claude_session.query_json(_prompt(row), system=SYSTEM) + except Exception as e: # noqa: BLE001 — log and skip, never crash the batch + print(f" ! {row['id']}: judge error ({e}) — skipped", flush=True) + return None + role = "" + if isinstance(raw, dict): + role = str(raw.get("role") or "").strip().lower() + if role not in VALID_ROLES: + print(f" ? {row['id']}: invalid role {role!r} — skipped", flush=True) + return None + return role + + +async def _fetch_legacy_rows() -> list[dict]: + pool = await db.get_pool() + rows = await pool.fetch( + "SELECT h.id, h.rule_type, h.rule_statement, h.reasoning_summary, " + " h.supporting_quote, cl.case_number, cl.precedent_level " + "FROM halachot h LEFT JOIN case_law cl ON cl.id = h.case_law_id " + "WHERE h.rule_type IN ('binding','persuasive') " + "ORDER BY h.case_law_id, h.halacha_index" + ) + return [dict(r) for r in rows] + + +def _backup(rows: list[dict]) -> Path: + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + AUDIT_DIR.mkdir(parents=True, exist_ok=True) + out = AUDIT_DIR / f"halacha-rule-role-backfill-backup-{ts}.csv" + with out.open("w", encoding="utf-8", newline="") as f: + w = csv.writer(f) + w.writerow(["id", "old_rule_type", "case_number", "precedent_level"]) + for r in rows: + w.writerow([r["id"], r["rule_type"], r.get("case_number") or "", + r.get("precedent_level") or ""]) + return out + + +async def main(args: argparse.Namespace) -> int: + rows = await _fetch_legacy_rows() + if args.limit: + rows = rows[: args.limit] + print(f"legacy binding/persuasive rows to reclassify: {len(rows)}", flush=True) + if not rows: + return 0 + + backup = _backup(rows) + print(f"backup written → {backup}", flush=True) + + pool = await db.get_pool() + changed = skipped = 0 + sem = asyncio.Semaphore(args.concurrency) + + async def _one(row: dict): + nonlocal changed, skipped + async with sem: + role = await _classify(row) + if role is None: + skipped += 1 + return + old = row["rule_type"] + print(f" {row.get('case_number') or '':<14} {old:>10} → {role}", flush=True) + if args.apply and role != old: + await pool.execute( + "UPDATE halachot SET rule_type = $2, updated_at = now() WHERE id = $1", + row["id"], role, + ) + changed += 1 + + # process in chunks to bound concurrent CLI subprocesses + for i in range(0, len(rows), args.concurrency): + await asyncio.gather(*(_one(r) for r in rows[i : i + args.concurrency])) + + mode = "APPLIED" if args.apply else "DRY-RUN (no writes)" + print(f"\n{mode}: {changed} reclassified, {skipped} skipped (kept old).", flush=True) + if not args.apply: + print("re-run with --apply to write changes.", flush=True) + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)") + ap.add_argument("--limit", type=int, default=0, help="only first N rows (smoke test)") + ap.add_argument("--concurrency", type=int, default=4, help="parallel judge calls") + sys.exit(asyncio.run(main(ap.parse_args()))) diff --git a/web-ui/src/components/goldset/goldset-panel.tsx b/web-ui/src/components/goldset/goldset-panel.tsx index 5144440..cf4bc89 100644 --- a/web-ui/src/components/goldset/goldset-panel.tsx +++ b/web-ui/src/components/goldset/goldset-panel.tsx @@ -11,20 +11,22 @@ import { useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample, type GoldsetItem, } from "@/lib/api/goldset"; +import { AuthorityBadge } from "@/components/precedents/halacha-meta"; +// rule ROLE only (INV-DM7) — authority (binding/persuasive) is a SEPARATE, +// derived axis, shown read-only and never tagged here. const TYPES: { value: string; label: string }[] = [ - { value: "binding", label: "מחייבת" }, + { value: "holding", label: "מהותי" }, { value: "interpretive", label: "פרשני" }, + { value: "procedural", label: "פרוצדורלי" }, { value: "application", label: "יישום" }, { value: "obiter", label: "אמרת-אגב" }, - { value: "procedural", label: "פרוצדורלי" }, - { value: "persuasive", label: "משכנע" }, ]; -// Consistency between is_holding and the type (#81.7): a real holding is -// binding/interpretive/procedural/persuasive; a NON-holding is its reason — +// Consistency between is_holding and the role (#81.7): a real holding is +// holding/interpretive/procedural; a NON-holding is its reason — // application (fact-bound) or obiter (not decided). Other pairings contradict. -const HOLDING_TYPES = new Set(["binding", "interpretive", "procedural", "persuasive"]); +const HOLDING_TYPES = new Set(["holding", "interpretive", "procedural"]); const NON_HOLDING_TYPES = new Set(["application", "obiter"]); function inconsistentTag(it: GoldsetItem): string | null { @@ -33,7 +35,7 @@ function inconsistentTag(it: GoldsetItem): string | null { return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה."; } if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) { - return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב."; + return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מהותי/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב."; } return null; } @@ -139,11 +141,13 @@ function ScorePanel({ batch }: { batch: string }) { // ─── Rule-type help (info popover) ──────────────────────────────────────────── +// Role only — "כמה מחייב" (מחייב/משכנע) is the SEPARATE authority axis, derived +// automatically from the court's identity and shown as a read-only badge. const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [ { - label: "מחייבת", - def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.", - test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.", + label: "מהותי", + def: "העיקרון המהותי שהיה הכרחי להכרעה — ה-ratio האמיתי. בר-הסתמכות מלא.", + test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מהותי.", example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.", }, { @@ -152,6 +156,12 @@ const TYPE_HELP: { label: string; def: string; test: string; example: string }[] test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.", example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.", }, + { + label: "פרוצדורלי", + def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.", + test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.", + example: "המועד להגשת ערר הוא 30 יום.", + }, { label: "יישום", def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').", @@ -160,22 +170,10 @@ const TYPE_HELP: { label: string; def: string; test: string; example: string }[] }, { label: "אמרת-אגב", - def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.", + def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו.", test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.", example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...", }, - { - label: "פרוצדורלי", - def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.", - test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.", - example: "המועד להגשת ערר הוא 30 יום.", - }, - { - label: "משכנע", - def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.", - test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.", - example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.", - }, ]; function RuleTypeHelp() { @@ -195,7 +193,7 @@ function RuleTypeHelp() {

סוגי ההלכה — במה הם נבדלים

- כלל-אצבע: סימנת "הלכה" → לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" → לרוב יישום / אמרת-אגב. + כלל-אצבע: סימנת “הלכה” → לרוב מהותי / פרשני / פרוצדורלי. סימנת “לא” → לרוב יישום / אמרת-אגב.

    @@ -238,6 +236,7 @@ function TagCard({ {sourceLabel(it.source_type)} מכונה: {it.rule_type} + {it.confidence != null && ( ביטחון {it.confidence.toFixed(2)} )} diff --git a/web-ui/src/components/precedents/extracted-halachot.tsx b/web-ui/src/components/precedents/extracted-halachot.tsx index 018ec3a..5292dd3 100644 --- a/web-ui/src/components/precedents/extracted-halachot.tsx +++ b/web-ui/src/components/precedents/extracted-halachot.tsx @@ -7,15 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CorroborationBadge } from "@/components/precedents/corroboration-badge"; import { useUpdateHalacha, type Halacha } from "@/lib/api/precedent-library"; - -const RULE_TYPE_LABELS: Record = { - binding: "הלכה מחייבת", - interpretive: "פרשני", - procedural: "פרוצדורלי", - obiter: "אמרת אגב", - application: "יישום הלכה", - persuasive: "משכנע", -}; +import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta"; type StatusFilter = "all" | "approved" | "pending" | "rejected"; @@ -172,8 +164,9 @@ export function ExtractedHalachotSection({ halachot }: { halachot: Halacha[] }) - {RULE_TYPE_LABELS[h.rule_type] ?? h.rule_type} + {ruleTypeLabel(h.rule_type)} + ביטחון {h.confidence.toFixed(2)} diff --git a/web-ui/src/components/precedents/halacha-meta.tsx b/web-ui/src/components/precedents/halacha-meta.tsx new file mode 100644 index 0000000..6498b9c --- /dev/null +++ b/web-ui/src/components/precedents/halacha-meta.tsx @@ -0,0 +1,54 @@ +"use client"; +/** + * Shared halacha-classification labels + badge (INV-DM7 — two orthogonal axes). + * + * rule_type holds the rule ROLE (what KIND of statement). authority (binding vs + * persuasive) is a SEPARATE, DERIVED axis (where it came from) — rendered as a + * distinct read-only badge, never mixed into the role label. + */ +import { Badge } from "@/components/ui/badge"; + +/** rule ROLE labels. Legacy authority values (binding/persuasive) are kept as a + * fallback so pre-backfill rows still render a Hebrew word during rollout. */ +export const RULE_TYPE_LABELS: Record = { + holding: "עיקרון מהותי", + interpretive: "פרשני", + procedural: "פרוצדורלי", + application: "יישום", + obiter: "אמרת אגב", + // legacy (pre-split) — fold to role wording until backfill completes + binding: "עיקרון מהותי", + persuasive: "פרשני", +}; + +export function ruleTypeLabel(t: string | null | undefined): string { + return (t && RULE_TYPE_LABELS[t]) || t || "—"; +} + +export const AUTHORITY_LABELS: Record = { + binding: "מחייב", + persuasive: "משכנע", +}; + +/** Read-only authority badge — derived from the source, the chair never sets it. */ +export function AuthorityBadge({ + authority, +}: { + authority?: string | null; +}) { + if (!authority || !AUTHORITY_LABELS[authority]) return null; + const isBinding = authority === "binding"; + return ( + + {AUTHORITY_LABELS[authority]} + + ); +} diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index 7b9df21..59bed2e 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -12,6 +12,7 @@ import { practiceAreaLabel } from "./practice-area"; import { useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha, } from "@/lib/api/precedent-library"; +import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta"; /** #81 strict-rubric flags — why an item was held back from auto-approval. */ const QUALITY_FLAG_LABELS: Record = { @@ -40,19 +41,6 @@ function cleanCitation(s: string | null | undefined): string { return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); } -const RULE_TYPE_LABELS: Record = { - binding: "הלכה מחייבת", - interpretive: "פרשני", - procedural: "פרוצדורלי", - obiter: "אמרת אגב", - application: "יישום הלכה", - persuasive: "משכנע", -}; - -function ruleTypeLabel(t: string): string { - return RULE_TYPE_LABELS[t] ?? t; -} - type EditState = { rule_statement: string; reasoning_summary: string }; // ─── Pending-queue card (full interactions) ─────────────────────────────────── @@ -118,6 +106,7 @@ function HalachaCard({ {ruleTypeLabel(h.rule_type)} + {variants.length > 0 && ( @@ -326,6 +315,7 @@ function HalachaRestoreCard({ {ruleTypeLabel(h.rule_type)} + diff --git a/web-ui/src/lib/api/goldset.ts b/web-ui/src/lib/api/goldset.ts index 976254b..a35baf8 100644 --- a/web-ui/src/lib/api/goldset.ts +++ b/web-ui/src/lib/api/goldset.ts @@ -23,6 +23,8 @@ export type GoldsetItem = { supporting_quote: string; reasoning_summary: string; rule_type: string; + // authority over the committee — DERIVED from the source (INV-DM7), read-only. + authority?: "binding" | "persuasive" | null; confidence: number | null; quality_flags?: string[]; review_status: string; diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 95ba14f..bc9dbbc 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -65,6 +65,8 @@ export type Halacha = { halacha_index: number; rule_statement: string; rule_type: string; + // authority over the committee — DERIVED from the source (INV-DM7), read-only. + authority?: "binding" | "persuasive" | null; reasoning_summary: string; supporting_quote: string; page_reference: string; @@ -138,6 +140,7 @@ export type SearchHit = subject_tags: string[]; confidence: number; rule_type: string; + authority?: "binding" | "persuasive" | null; case_number: string; case_name: string; court: string; diff --git a/web/app.py b/web/app.py index ced4781..4350d34 100644 --- a/web/app.py +++ b/web/app.py @@ -6343,8 +6343,10 @@ async def goldset_tag_ep(goldset_id: str, req: GoldsetTagRequest): gid = UUID(goldset_id) except ValueError: raise HTTPException(400, "מזהה לא תקין") + # correct_type is the rule ROLE only (INV-DM7) — authority (binding/ + # persuasive) is derived, not a human-tagged value. if req.correct_type and req.correct_type not in ( - "binding", "interpretive", "obiter", "application", "procedural", "persuasive", + "holding", "interpretive", "procedural", "application", "obiter", ): raise HTTPException(400, "correct_type לא תקין") row = await db.goldset_tag(