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:
@@ -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) |
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
|
||||
151
scripts/halacha_rule_role_backfill.py
Normal file
151
scripts/halacha_rule_role_backfill.py
Normal 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())))
|
||||
Reference in New Issue
Block a user