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`. **אכיפה:** 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)). **הפרה ידועה:** 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 ## 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, case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
halacha_index INTEGER NOT NULL, halacha_index INTEGER NOT NULL,
rule_statement TEXT NOT NULL, rule_statement TEXT NOT NULL,
rule_type TEXT DEFAULT 'binding', rule_type TEXT DEFAULT 'interpretive',
-- binding | interpretive | procedural | obiter -- 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 '', reasoning_summary TEXT DEFAULT '',
supporting_quote TEXT NOT NULL, supporting_quote TEXT NOT NULL,
page_reference TEXT DEFAULT '', page_reference TEXT DEFAULT '',
@@ -4052,7 +4054,7 @@ async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int:
case_law_id, case_law_id,
i, i,
h["rule_statement"], h["rule_statement"],
h.get("rule_type", "binding"), h.get("rule_type", "interpretive"),
h.get("reasoning_summary", ""), h.get("reasoning_summary", ""),
h["supporting_quote"], h["supporting_quote"],
h.get("page_reference", ""), 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, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, {reviewed_at_clause})""", $12, $13, $14, $15, $16, {reviewed_at_clause})""",
case_law_id, base + inserted, h["rule_statement"], 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["supporting_quote"], h.get("page_reference", ""),
h.get("practice_areas", []), h.get("subject_tags", []), h.get("practice_areas", []), h.get("subject_tags", []),
h.get("cites", []), confidence, h.get("quote_verified", False), h.get("cites", []), confidence, h.get("quote_verified", False),
@@ -4299,6 +4301,8 @@ async def list_halachot(
d = dict(r) d = dict(r)
if d.get("decision_date") is not None: if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat() 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) out.append(d)
if cluster and out: if cluster and out:
await _annotate_clusters(pool, 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, " " 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_statement, h.supporting_quote, h.reasoning_summary, "
" h.rule_type, h.confidence, h.quality_flags, h.review_status, " " 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 " "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 " "LEFT JOIN case_law cl ON cl.id = h.case_law_id "
"WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch, "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() d["ai_generated_at"] = d["ai_generated_at"].isoformat()
if d.get("confidence") is not None: if d.get("confidence") is not None:
d["confidence"] = float(d["confidence"]) 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) out.append(d)
return out return out
@@ -4792,7 +4798,7 @@ async def goldset_score(batch: str = "default") -> dict:
for r in labeled: for r in labeled:
rule = r.get("rule_statement") or "" rule = r.get("rule_statement") or ""
quote = r.get("supporting_quote") 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 qc = r["quote_complete"] if r["quote_complete"] is not None else True
truly_bad = r["is_holding"] is False truly_bad = r["is_holding"] is False
flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype) 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) _conf = float(d.get("confidence") or 0.0)
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0) d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
d["type"] = "halacha" 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) results.append(d)
rows = await pool.fetch(chunk_sql, *c_params) 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" # wants to be able to cite "another committee reached the same conclusion"
# even though it is not binding. # even though it is not binding.
# #
# The schema's rule_type field accepts six values: # The prompt branches on is_binding only to choose the EXTRACTION STRATEGY
# binding | interpretive | procedural | obiter | application | persuasive # (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 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי). HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
@@ -101,10 +105,12 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד. הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
## סוגי הלכה (rule_type) ## סוג הכלל (rule_type) — מהות הכלל בלבד, לא סמכות-המקור
- binding — הלכה מחייבת שהוחלה על התיק. **אל תסווג "מחייב/משכנע"** — דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה. כאן בחר רק את **סוג הכלל**:
- interpretive — פרשנות סעיף חוק/תכנית שאומצה. - holding — עיקרון מהותי שהיה הכרחי להכרעה (ה-ratio; מבחן Wambaugh: שלילתו הייתה משנה את התוצאה).
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה). - interpretive — פרשנות הוראת-חוק/מונח/תכנית שאומצה.
- procedural — כלל סדר-דין (סמכות, מועדים, זכות-עמידה, מיצוי הליכים, נטל).
- application — החלת כלל על עובדות התיק (תלוי-עובדות; לרוב לא-הלכה בת-הכללה).
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך). - obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
## פלט נדרש ## פלט נדרש
@@ -112,7 +118,7 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
[ [
{ {
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.", "rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
"rule_type": "binding", "rule_type": "holding",
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).", "reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.", "supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.", "page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
@@ -139,11 +145,11 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת. המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
**יש לחלץ:** **יש לחלץ** (סווג לפי **סוג הכלל** בלבד — אל תסווג "מחייב/משכנע", דרגת-המחייבות נגזרת אוטומטית):
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה. - **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ. - **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך. - **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת. - **מסקנה מהותית מנומקת** (rule_type=`holding`) — מסקנה עקרונית שלמה של הפנל בסוגיה, עם ההיגיון התומך, בת-הכללה ובת-הסתמכות.
**אין לחלץ:** **אין לחלץ:**
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד. - ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
@@ -175,7 +181,7 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
## כללי איכות ## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה. 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר []. 2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא. 3. **rule_type מדויק (סוג הכלל בלבד)** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. holding = מסקנה מהותית עקרונית. **לא** binding/persuasive (סמכות נגזרת אוטומטית).
4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים. 4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
5. **שפה** — עברית משפטית מקצועית, גוף שלישי. 5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
6. **subject_tags** — 2-5 תגיות בעברית, snake_case. 6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
@@ -184,10 +190,15 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"} _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 = { _VALID_RULE_TYPES = {
"binding", "interpretive", "procedural", "obiter", "holding", "interpretive", "procedural", "application", "obiter",
"application", "persuasive",
} }
# 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: def _normalize_for_comparison(text: str) -> str:
@@ -227,13 +238,14 @@ def _verify_quote(supporting_quote: str, full_text: str) -> bool:
return False 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. """Validate and normalize one LLM-returned halacha dict.
Returns ``None`` if the entry is missing required fields. ``is_binding`` Returns ``None`` if the entry is missing required fields. ``rule_type`` is
only affects the default rule_type when the LLM returned an unknown the rule ROLE only (INV-DM7) — it is NEVER defaulted from the source's
value — for binding sources we default to ``binding``, otherwise to bindingness (that was the source-conflation this split removed). Legacy
``persuasive`` (never pretend an appeals committee created halacha). authority values fold to the nearest role; unknown defaults to
``interpretive`` (the most common role).
""" """
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None 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: if not rule_statement or not supporting_quote:
return None return None
default_rule_type = "binding" if is_binding else "persuasive" rule_type = (raw.get("rule_type") or "").strip().lower()
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower() rule_type = _LEGACY_RULE_TYPE_FOLD.get(rule_type, rule_type)
if rule_type not in _VALID_RULE_TYPES: if rule_type not in _VALID_RULE_TYPES:
rule_type = default_rule_type rule_type = "interpretive"
# Guard: don't let a non-binding source produce 'binding' rule_type
if not is_binding and rule_type == "binding":
rule_type = "persuasive"
practice_areas_raw = raw.get("practice_areas") or [] practice_areas_raw = raw.get("practice_areas") or []
if isinstance(practice_areas_raw, str): if isinstance(practice_areas_raw, str):
@@ -580,7 +589,7 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
return return
cleaned: list[dict] = [] cleaned: list[dict] = []
for raw in items: for raw in items:
coerced = _coerce_halacha(raw, is_binding=is_binding) coerced = _coerce_halacha(raw)
if coerced is None: if coerced is None:
continue continue
coerced["quote_verified"] = _verify_quote( coerced["quote_verified"] = _verify_quote(
@@ -597,10 +606,10 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
coerced["quality_flags"] = flags coerced["quality_flags"] = flags
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter": if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
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). # re-typed application (it carries FLAG_APPLICATION either way).
elif (halacha_quality.FLAG_APPLICATION in flags elif (halacha_quality.FLAG_APPLICATION in flags
and coerced["rule_type"] == "binding"): and coerced["rule_type"] == "holding"):
coerced["rule_type"] = "application" coerced["rule_type"] = "application"
cleaned.append(coerced) cleaned.append(coerced)
# #81.3 NLI entailment — one batched judge call per chunk (fail-open). # #81.3 NLI entailment — one batched judge call per chunk (fail-open).

View File

@@ -18,6 +18,37 @@ from __future__ import annotations
import re 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) ── # ── Hebrew text normalization (shared with the extractor's quote check) ──
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″" _HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
@@ -337,7 +368,7 @@ def compute_quality_flags(
supporting_quote: str, supporting_quote: str,
reasoning_summary: str = "", reasoning_summary: str = "",
quote_verified: bool = True, quote_verified: bool = True,
rule_type: str = "binding", rule_type: str = "interpretive",
) -> list[str]: ) -> list[str]:
"""Return the list of quality flags for one halacha (empty == clean). """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 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( flags = hq.compute_quality_flags(
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר", "במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
rule_type="binding", rule_type="holding",
) )
assert hq.FLAG_APPLICATION in flags 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( flags = hq.compute_quality_flags(
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית", "ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.", "הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
rule_type="binding", rule_type="holding",
) )
assert flags == [] 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 ── # ── #82.3 lexical near-duplicate signal ──
def test_jaccard_high_for_reworded_same_rule(): 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) | | `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 | | `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 | | `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` לקישור | | `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) | | `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) | | `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 from legal_mcp.services import claude_session, db
VALID_TYPES = {"binding", "interpretive", "obiter", "application", "procedural", "persuasive"} VALID_TYPES = {"holding", "interpretive", "procedural", "application", "obiter"}
SYSTEM = ( SYSTEM = (
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. " "אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n" "לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום " "1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n" "תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
"2) type — הסוג הנכון: 'binding' (עיקרון הכרחי להכרעה), 'interpretive' (פרשנות " "2) type — **סוג הכלל בלבד** (אל תסווג מחייב/משכנע — דרגת-המחייבות נגזרת אוטומטית "
"חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), 'persuasive' " "מזהות הערכאה): 'holding' (עיקרון מהותי שהיה הכרחי להכרעה — ratio), 'interpretive' "
"(אסמכתה לא-מחייבת), 'application' (החלה על עובדות התיק — לרוב לא-הלכה), " "(פרשנות חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), "
"'obiter' (אמרת-אגב שלא הוכרעה — לא-הלכה).\n" "'application' (החלה על עובדות התיק — לרוב לא-הלכה), 'obiter' (אמרת-אגב שלא "
"עקביות: is_holding=true → binding/interpretive/procedural/persuasive; " "הוכרעה — לא-הלכה).\n"
"עקביות: is_holding=true → holding/interpretive/procedural; "
"is_holding=false → application/obiter.\n" "is_holding=false → application/obiter.\n"
'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהשישה>", ' 'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהחמישה>", '
'"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.' '"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, useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
type GoldsetItem, type GoldsetItem,
} from "@/lib/api/goldset"; } 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 }[] = [ const TYPES: { value: string; label: string }[] = [
{ value: "binding", label: חייבת" }, { value: "holding", label: הותי" },
{ value: "interpretive", label: "פרשני" }, { value: "interpretive", label: "פרשני" },
{ value: "procedural", label: "פרוצדורלי" },
{ value: "application", label: "יישום" }, { value: "application", label: "יישום" },
{ value: "obiter", label: "אמרת-אגב" }, { value: "obiter", label: "אמרת-אגב" },
{ value: "procedural", label: "פרוצדורלי" },
{ value: "persuasive", label: "משכנע" },
]; ];
// Consistency between is_holding and the type (#81.7): a real holding is // Consistency between is_holding and the role (#81.7): a real holding is
// binding/interpretive/procedural/persuasive; a NON-holding is its reason — // holding/interpretive/procedural; a NON-holding is its reason —
// application (fact-bound) or obiter (not decided). Other pairings contradict. // 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"]); const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
function inconsistentTag(it: GoldsetItem): string | null { function inconsistentTag(it: GoldsetItem): string | null {
@@ -33,7 +35,7 @@ function inconsistentTag(it: GoldsetItem): string | null {
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה."; return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
} }
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) { if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב."; return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מהותי/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
} }
return null; return null;
} }
@@ -139,11 +141,13 @@ function ScorePanel({ batch }: { batch: string }) {
// ─── Rule-type help (info popover) ──────────────────────────────────────────── // ─── 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 }[] = [ const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
{ {
label: חייבת", label: הותי",
def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.", def: "העיקרון המהותי שהיה הכרחי להכרעה — ה-ratio האמיתי. בר-הסתמכות מלא.",
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.", test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מהותי.",
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.", example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
}, },
{ {
@@ -152,6 +156,12 @@ const TYPE_HELP: { label: string; def: string; test: string; example: string }[]
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.", test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.", example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
}, },
{
label: "פרוצדורלי",
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
example: "המועד להגשת ערר הוא 30 יום.",
},
{ {
label: "יישום", label: "יישום",
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').", def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
@@ -160,22 +170,10 @@ const TYPE_HELP: { label: string; def: string; test: string; example: string }[]
}, },
{ {
label: "אמרת-אגב", label: "אמרת-אגב",
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.", def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו.",
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.", test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...", example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
}, },
{
label: "פרוצדורלי",
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
example: "המועד להגשת ערר הוא 30 יום.",
},
{
label: "משכנע",
def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.",
test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.",
example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.",
},
]; ];
function RuleTypeHelp() { function RuleTypeHelp() {
@@ -195,7 +193,7 @@ function RuleTypeHelp() {
<div className="p-3 border-b border-rule"> <div className="p-3 border-b border-rule">
<p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p> <p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p>
<p className="text-[0.72rem] text-ink-muted mt-0.5"> <p className="text-[0.72rem] text-ink-muted mt-0.5">
כלל-אצבע: סימנת "הלכה" לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" לרוב יישום / אמרת-אגב. כלל-אצבע: סימנת &ldquo;הלכה&rdquo; לרוב מהותי / פרשני / פרוצדורלי. סימנת &ldquo;לא&rdquo; לרוב יישום / אמרת-אגב.
</p> </p>
</div> </div>
<ul className="divide-y divide-rule-soft"> <ul className="divide-y divide-rule-soft">
@@ -238,6 +236,7 @@ function TagCard({
{sourceLabel(it.source_type)} {sourceLabel(it.source_type)}
</Badge> </Badge>
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge> <Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
<AuthorityBadge authority={it.authority} />
{it.confidence != null && ( {it.confidence != null && (
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge> <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 { Button } from "@/components/ui/button";
import { CorroborationBadge } from "@/components/precedents/corroboration-badge"; import { CorroborationBadge } from "@/components/precedents/corroboration-badge";
import { useUpdateHalacha, type Halacha } from "@/lib/api/precedent-library"; import { useUpdateHalacha, type Halacha } from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
const RULE_TYPE_LABELS: Record<string, string> = {
binding: "הלכה מחייבת",
interpretive: "פרשני",
procedural: "פרוצדורלי",
obiter: "אמרת אגב",
application: "יישום הלכה",
persuasive: "משכנע",
};
type StatusFilter = "all" | "approved" | "pending" | "rejected"; type StatusFilter = "all" | "approved" | "pending" | "rejected";
@@ -172,8 +164,9 @@ export function ExtractedHalachotSection({ halachot }: { halachot: Halacha[] })
</span> </span>
<ReviewStatusPill status={h.review_status} /> <ReviewStatusPill status={h.review_status} />
<Badge variant="outline" className="text-[0.65rem]"> <Badge variant="outline" className="text-[0.65rem]">
{RULE_TYPE_LABELS[h.rule_type] ?? h.rule_type} {ruleTypeLabel(h.rule_type)}
</Badge> </Badge>
<AuthorityBadge authority={h.authority} />
<Badge variant="outline" className="text-[0.65rem] tabular-nums"> <Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)} ביטחון {h.confidence.toFixed(2)}
</Badge> </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 { import {
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha, useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
/** #81 strict-rubric flags — why an item was held back from auto-approval. */ /** #81 strict-rubric flags — why an item was held back from auto-approval. */
const QUALITY_FLAG_LABELS: Record<string, string> = { const QUALITY_FLAG_LABELS: Record<string, string> = {
@@ -40,19 +41,6 @@ function cleanCitation(s: string | null | undefined): string {
return s.replace(/[--]/g, "").trim(); 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 }; type EditState = { rule_statement: string; reasoning_summary: string };
// ─── Pending-queue card (full interactions) ─────────────────────────────────── // ─── Pending-queue card (full interactions) ───────────────────────────────────
@@ -118,6 +106,7 @@ function HalachaCard({
<Badge variant="outline" className="text-[0.65rem]"> <Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)} {ruleTypeLabel(h.rule_type)}
</Badge> </Badge>
<AuthorityBadge authority={h.authority} />
{variants.length > 0 && ( {variants.length > 0 && (
<Badge variant="outline" <Badge variant="outline"
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30"> 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]"> <Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)} {ruleTypeLabel(h.rule_type)}
</Badge> </Badge>
<AuthorityBadge authority={h.authority} />
<CorroborationBadge halacha={h} /> <CorroborationBadge halacha={h} />
</span> </span>
</div> </div>

View File

@@ -23,6 +23,8 @@ export type GoldsetItem = {
supporting_quote: string; supporting_quote: string;
reasoning_summary: string; reasoning_summary: string;
rule_type: string; rule_type: string;
// authority over the committee — DERIVED from the source (INV-DM7), read-only.
authority?: "binding" | "persuasive" | null;
confidence: number | null; confidence: number | null;
quality_flags?: string[]; quality_flags?: string[];
review_status: string; review_status: string;

View File

@@ -65,6 +65,8 @@ export type Halacha = {
halacha_index: number; halacha_index: number;
rule_statement: string; rule_statement: string;
rule_type: string; rule_type: string;
// authority over the committee — DERIVED from the source (INV-DM7), read-only.
authority?: "binding" | "persuasive" | null;
reasoning_summary: string; reasoning_summary: string;
supporting_quote: string; supporting_quote: string;
page_reference: string; page_reference: string;
@@ -138,6 +140,7 @@ export type SearchHit =
subject_tags: string[]; subject_tags: string[];
confidence: number; confidence: number;
rule_type: string; rule_type: string;
authority?: "binding" | "persuasive" | null;
case_number: string; case_number: string;
case_name: string; case_name: string;
court: string; court: string;

View File

@@ -6139,8 +6139,10 @@ async def goldset_tag_ep(goldset_id: str, req: GoldsetTagRequest):
gid = UUID(goldset_id) gid = UUID(goldset_id)
except ValueError: except ValueError:
raise HTTPException(400, "מזהה לא תקין") 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 ( 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 לא תקין") raise HTTPException(400, "correct_type לא תקין")
row = await db.goldset_tag( row = await db.goldset_tag(