fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)

The extractor classified rule_type by SOURCE bindingness (higher-court→binding,
committee→persuasive) instead of by rule KIND. The gold-set proved it: 'binding'
appeared on 19/19 external rulings & 0 committees; 'persuasive' on 13/13
committees & 0 external — only 58% agreement with the human role tags. The two
axes (authority vs rule role) were crammed into one enum.

This splits them per INV-DM7:
- authority (binding/persuasive) — DERIVED from case_law.precedent_level
  (עליון/מנהלי→binding, ועדת_ערר_מחוזית→persuasive), never stored, never
  LLM-guessed. New helper halacha_quality.derive_authority; surfaced read-only
  in list_halachot / goldset_list / search results.
- rule_type — now the rule ROLE only: holding/interpretive/procedural/
  application/obiter. Both extractor prompts unified to this vocabulary;
  _coerce_halacha no longer defaults rule_type from the source; legacy
  binding→holding / persuasive→interpretive fold for safety.

UI: authority shown as a separate read-only badge (gold=מחייב / muted=משכנע)
across the review queue, precedent detail, and gold-set; the gold-set role
selector drops binding/persuasive and adds מהותי (holding).

Migration: scripts/halacha_rule_role_backfill.py re-classifies the 276 pre-split
binding/persuasive rows into a genuine role via local claude_session (run after
deploy). Gold-set correct_type/ai_correct_type 'binding'→'holding' via SQL.

Sources (≥3, per research-decision policy): OASIS LegalRuleML v1.0
(appliesAuthority/Strength as metadata orthogonal to rule logic) · SemEval-2023
Task 6 LegalEval (rhetorical roles by function, authority kept separate) ·
Bluebook signals (weight-of-authority is a separate dimension).

Invariants: ESTABLISHES INV-DM7. Upholds G1 (normalize at source — extractor
classifies role, system derives authority) and G2 (single source of truth —
authority derived, not a parallel stored field). Tests: 211 pass + new
derive_authority/coerce coverage. web-ui build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:18:41 +00:00
parent 955675eb1f
commit 2e33cac043
16 changed files with 407 additions and 92 deletions

View File

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

View File

@@ -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 '',
@@ -4052,7 +4054,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", ""),
@@ -4193,7 +4195,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),
@@ -4299,6 +4301,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)
@@ -4721,7 +4725,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,
@@ -4735,6 +4739,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
@@ -4792,7 +4798,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)
@@ -4990,6 +4996,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)

View File

@@ -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).

View File

@@ -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).

View File

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

View File

@@ -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():

View File

@@ -40,6 +40,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 <num>` / `--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>` קורא את ה-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 <path>`. רץ עם 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) |

View File

@@ -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.'
)

View File

@@ -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())))

View File

@@ -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() {
<div className="p-3 border-b border-rule">
<p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p>
<p className="text-[0.72rem] text-ink-muted mt-0.5">
כלל-אצבע: סימנת "הלכה" לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" לרוב יישום / אמרת-אגב.
כלל-אצבע: סימנת &ldquo;הלכה&rdquo; לרוב מהותי / פרשני / פרוצדורלי. סימנת &ldquo;לא&rdquo; לרוב יישום / אמרת-אגב.
</p>
</div>
<ul className="divide-y divide-rule-soft">
@@ -238,6 +236,7 @@ function TagCard({
{sourceLabel(it.source_type)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
<AuthorityBadge authority={it.authority} />
{it.confidence != null && (
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
)}

View File

@@ -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<string, string> = {
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[] })
</span>
<ReviewStatusPill status={h.review_status} />
<Badge variant="outline" className="text-[0.65rem]">
{RULE_TYPE_LABELS[h.rule_type] ?? h.rule_type}
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)}
</Badge>

View File

@@ -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<string, string> = {
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<string, string> = {
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 (
<Badge
variant="outline"
title="דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה"
className={
isBinding
? "text-[0.65rem] bg-gold/15 text-navy border-gold/50"
: "text-[0.65rem] bg-muted text-ink-muted border-border"
}
>
{AUTHORITY_LABELS[authority]}
</Badge>
);
}

View File

@@ -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<string, string> = {
@@ -40,19 +41,6 @@ function cleanCitation(s: string | null | undefined): string {
return s.replace(/[--]/g, "").trim();
}
const RULE_TYPE_LABELS: Record<string, string> = {
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({
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
{variants.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
@@ -326,6 +315,7 @@ function HalachaRestoreCard({
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
<CorroborationBadge halacha={h} />
</span>
</div>

View File

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

View File

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

View File

@@ -6139,8 +6139,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(