feat(halacha): #86.2 nevo-leak audit + safe ratio backfill · #86.3 ratio-coverage benchmark
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s

#86.2 — scripts/nevo_corpus_audit.py leak: סורק chunks+הלכות למרקרי-preamble של נבו
(מיובאים מ-extractor._NEVO_MARKERS — מקור-אמת יחיד), מבחין בין הווקטור המזיק (מרקר בתוך
הלכה = רציו-עריכה שזוהה כהלכה) ל-benign (chunk עם רשימת-ציטוטים). **ממצא חי: 0/~1650
הלכות מזוהמות** — שכבת-הידע נקייה (שערי-האיכות של #81 מנעו זאת). לכן **אין purge/re-ingest**
(גם כי re-OCR retrofit נוגד-עיקרון, feedback_no_reocr_retrofit; וצ'אנקי-ציטוטים benign).
`leak --apply` עושה backfill **אדיטיבי** של case_law.nevo_ratio מ-full_text השמור
(extract_nevo_ratio, דטרמיניסטי, ללא re-OCR, לא נוגע בצ'אנקים/הלכות) — "לשמור במקום
למחוק". הורץ: 16→32 פסקים עם רציו שמור.

#86.3 — benchmark: לפסקים עם nevo_ratio, הפאנל התלת-מודלי שופט אילו עקרונות-רציו מכוסים
ע"י ההלכות שלנו → recall. smoke: 1110-20 (13 הלכות) recall=1.0 (כיסוי מלא); פסקים עם
0 הלכות → recall=0 (אות-פער-חילוץ אמיתי, לא כשל-כיסוי). מזין את אות-האיכות של #81.7.

invariants: G2 (מרקרים+strip מיובאים מ-extractor; פאנל מ-halacha_panel_approve) ·
INV-G10 (read-only/אדיטיבי; אין מחיקה) · no-reocr (backfill מטקסט שמור, לא חילוץ-מחדש).
tests: 6 offline (_has_marker/_has_editorial) + nevo_preamble קיים. אומת חי.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:50:50 +00:00
parent ff2d28b1a7
commit ec14e8310b
3 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
"""Tests for #86.2 — pure marker classifiers in scripts/nevo_corpus_audit.py.
Distinguishes the harmful editorial-ratio markers (מיני-רציו / מבזק) from benign
Nevo citation-list markers (חקיקה שאוזכרה / ספרות). Offline.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
import nevo_corpus_audit as n # noqa: E402
def test_has_marker_detects_any_nevo_marker():
assert n._has_marker("חקיקה שאוזכרה: חוק התכנון והבניה")
assert n._has_marker("מיני-רציו: העותר לא הוכיח")
assert not n._has_marker("פסק-דין רגיל ללא מטא-דאטה של נבו")
def test_has_editorial_only_for_holdings_markers():
# editorial = the harmful family (a holdings summary that could be mistaken
# for our own extracted holding)
assert n._has_editorial("מיני-רציו: ...")
assert n._has_editorial("מבזק: בית המשפט קבע")
# a bare citation list is NOT editorial — benign
assert not n._has_editorial("חקיקה שאוזכרה: חוק התכנון והבניה, סע' 197")
assert not n._has_editorial("פסקי דין שאוזכרו: בר\"מ 2340/02")

View File

@@ -47,6 +47,7 @@
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
| `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_corpus_audit.py` | python | **#86.2/#86.3 — אודיט קורפוס-נבו (read-only).** `leak` סורק chunks+הלכות למרקרי-preamble של נבו (מיובאים מ-extractor._NEVO_MARKERS), מבחין בין הווקטור המזיק (מרקר בתוך הלכה=רציו-עריכה כהלכה) ל-benign (רשימת-ציטוטים), ומפיק CSV. אומת 2026-06-11: **0 הלכות מזוהמות** (שכבת-הידע נקייה) → אין purge/re-ingest (גם נוגד no-reocr). `leak --apply` מבצע backfill **אדיטיבי** של `case_law.nevo_ratio` מטקסט שמור (extract_nevo_ratio, ללא re-OCR) — captured 16→32. `benchmark` משווה הלכות-שלנו מול ה-מיני-רציו דרך הפאנל התלת-מודלי → recall כיסוי (1110-20: 13 הלכות, recall=1.0). **חובה מקומי** (benchmark). | ידני — ניטור-זיהום / ground-truth |
| `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_panel_label.py` | python | **#81.7 — תיוג ה-gold-set בקונצנזוס תלת-מודלי (ללא man-in-the-loop, הנחיית-יו"ר 2026-06-11).** מריץ את שלושת השופטים העצמאיים (Opus/claude_session · DeepSeek · Gemini, מיובאים מ-`halacha_panel_approve`) עם ה-prompt העשיר (`is_holding`+`type`+נימוק מ-`goldset_ai_recommend`) על כל פריט; **רוב 2/3 נכתב ל-`is_holding`/`correct_type`** עם `tagged_by='panel:opus+deepseek+gemini'`יצול→NULL→יו"ר, INV-G10). מודד **Fleiss κ** (3 מעריכים) ומריץ **מבחן-אנונימיזציה** (שמות-תיק ממוסכים→שיפוט-מחדש; flip=שינון). לא מעגלי — הוולידטורים הנמדדים rule-based. כותב per-model+consensus+anon ל-DB ודוח ל-`data/audit/`. **מחליף** תיוג-ידני; `goldset_ai_recommend`/`goldset_independent_judge` נשארים כבדיקות single-model. `--limit`/`--no-anon`/`--force`. **חובה מקומי**. | ידני — לאחר יצירת/הרחבת batch |
| `goldset_ai_recommend.py` | python | **#81.7 QA (single-model, נבלע ב-panel)** — חוות-דעת claude בלבד ל-`ai_*`. כעת לינאז' 1/3 בתוך `goldset_panel_label`; נשאר כבדיקת-claude עצמאית/חידוש נקודתי. `--force`/`--limit`. **חובה מקומי**. | ידני — בדיקה נקודתית |

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""#86.2 / #86.3 — audit the corpus for Nevo editorial-preamble leakage, and
benchmark our extracted halachot against Nevo's מיני-רציו (free expert ground-truth).
Two modes (read-only — no DB mutation, no re-ingest):
leak (#86.2) — scan precedent_chunks AND halachot for Nevo editorial markers
(_NEVO_MARKERS from extractor) that may have leaked in for rulings
ingested BEFORE the #86.1 strip fix. Distinguishes the HARMFUL vector
(markers inside extracted halachot — editorial ratio mistaken for a
holding) from the benign one (a citation-list chunk). Writes a CSV
report to data/audit/. Does NOT re-ingest: the knowledge layer
(halachot) is the only vector that matters, and re-OCR retrofit is
counter-indicated (non-deterministic OCR — see memory
feedback_no_reocr_retrofit); chunk-level citation-lists are low-harm.
benchmark (#86.3) — for rulings whose מיני-רציו was captured
(case_law.nevo_ratio), ask the tri-model panel which of the ratio's
holdings are covered by OUR extracted halachot → recall (coverage),
plus a granularity ratio (our holdings / ratio holdings). Nevo's ratio
is an independent expert summary, so this is a free quality signal that
complements the #81.7 gold-set.
Run locally (benchmark needs claude_session CLI + DeepSeek/Gemini keys):
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/nevo_corpus_audit.py leak
.venv/bin/python ../scripts/nevo_corpus_audit.py benchmark --limit 5
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import httpx
from legal_mcp.services import claude_session, db, extractor
sys.path.insert(0, str(Path(__file__).resolve().parent))
from halacha_panel_approve import judge_deepseek, judge_gemini # noqa: E402
AUDIT = Path(__file__).resolve().parent.parent / "data" / "audit"
# editorial-holdings markers (the harmful family) vs benign citation lists
_EDITORIAL = ("מיני-רציו", "מבזק")
def _has_marker(text: str) -> bool:
return any(m.rstrip(":") in (text or "") for m in extractor._NEVO_MARKERS)
def _has_editorial(text: str) -> bool:
return any(m in (text or "") for m in _EDITORIAL)
async def run_leak(args) -> int:
pool = await db.get_pool()
# chunks carrying any Nevo marker
chunk_rows = await pool.fetch(
"SELECT cl.id, cl.case_number, cl.source_type, pc.content "
"FROM precedent_chunks pc JOIN case_law cl ON cl.id = pc.case_law_id"
)
# halachot — the ONLY harmful vector (ratio mistaken for a holding)
hal_rows = await pool.fetch(
"SELECT cl.case_number, h.rule_statement, h.supporting_quote "
"FROM halachot h JOIN case_law cl ON cl.id = h.case_law_id"
)
per_case: dict = {}
for r in chunk_rows:
if _has_marker(r["content"]):
d = per_case.setdefault(r["case_number"], {
"source_type": r["source_type"], "marker_chunks": 0,
"editorial_chunks": 0, "marker_halachot": 0})
d["marker_chunks"] += 1
if _has_editorial(r["content"]):
d["editorial_chunks"] += 1
contaminated_halachot = 0
for r in hal_rows:
if _has_marker(r["rule_statement"]) or _has_marker(r["supporting_quote"]):
contaminated_halachot += 1
d = per_case.setdefault(r["case_number"], {
"source_type": "?", "marker_chunks": 0,
"editorial_chunks": 0, "marker_halachot": 0})
d["marker_halachot"] += 1
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
AUDIT.mkdir(parents=True, exist_ok=True)
out = AUDIT / f"nevo-leak-audit-{ts}.csv"
with out.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["case_number", "source_type", "marker_chunks",
"editorial_chunks", "marker_halachot"])
for cn, d in sorted(per_case.items(), key=lambda kv: -kv[1]["marker_halachot"]):
w.writerow([cn, d["source_type"], d["marker_chunks"],
d["editorial_chunks"], d["marker_halachot"]])
editorial_cases = sum(1 for d in per_case.values() if d["editorial_chunks"])
print(f"affected rulings (any marker): {len(per_case)}")
print(f" with EDITORIAL ratio chunks (מיני-רציו/מבזק): {editorial_cases}")
print(f" HALACHOT contaminated (the harmful vector): {contaminated_halachot}")
print(f"\nreport → {out}")
if contaminated_halachot == 0:
print("✓ knowledge layer clean — no halacha carries editorial ratio; "
"no purge/re-ingest warranted (chunk citation-lists are benign, "
"and re-OCR retrofit is counter-indicated).")
else:
print("⚠ contaminated halachot found — review the CSV; these need "
"targeted re-extraction (NOT bulk re-OCR).")
# Safe backfill (--apply): capture the מיני-רציו into case_law.nevo_ratio for
# pre-#86.1 rulings that have it in their stored text but never extracted it.
# Deterministic — runs extract_nevo_ratio on the STORED full_text (no re-OCR);
# writes only the additive nevo_ratio field (the ground-truth for #86.3),
# never touches chunks or halachot. "Capture, don't delete" (#86.3).
missing = await pool.fetch(
"SELECT id, case_number, full_text FROM case_law "
"WHERE COALESCE(nevo_ratio,'') = '' AND full_text LIKE '%מיני-רציו%'"
)
backfillable = [(r, extractor.extract_nevo_ratio(r["full_text"] or "")) for r in missing]
backfillable = [(r, ratio) for r, ratio in backfillable if ratio]
print(f"\nnevo_ratio backfill candidates (ratio in text, field empty): {len(backfillable)}")
if not args.apply:
print("(report-only — pass --apply to capture nevo_ratio for these)")
return 0
for r, ratio in backfillable:
await db.update_case_law(r["id"], nevo_ratio=ratio)
print(f"✓ captured nevo_ratio for {len(backfillable)} rulings (additive; "
f"chunks/halachot untouched).")
return 0
_BENCH_SYSTEM = (
"אתה בוחן-איכות משפטי. נתון 'מיני-רציו' של נבו (סיכום-העריכה של העקרונות שנקבעו "
"בפסק) ורשימת ההלכות שמערכת חילצה מאותו פסק. הכרע, לכל עיקרון ברציו, האם הוא "
"מכוסה ע\"י לפחות הלכה אחת שחולצה (אותו עיקרון משפטי, גם אם בניסוח שונה). "
'החזר JSON בלבד: {"ratio_points": <מספר עקרונות ברציו>, "covered": <כמה מהם מכוסים>, '
'"missing": ["<עיקרון שלא כוסה>", ...]}. ללא markdown.'
)
def _bench_user(ratio: str, halachot: list[str]) -> str:
ours = "\n".join(f"- {h}" for h in halachot) or "(אין)"
return f"מיני-רציו של נבו:\n{ratio}\n\nהלכות שחולצו אצלנו:\n{ours}"
async def run_benchmark(args) -> int:
pool = await db.get_pool()
cases = await pool.fetch(
"SELECT id, case_number, nevo_ratio FROM case_law "
"WHERE COALESCE(nevo_ratio,'') <> '' ORDER BY case_number"
)
if args.limit:
cases = cases[: args.limit]
print(f"rulings with stored nevo_ratio: {len(cases)}\n", flush=True)
results = []
async with httpx.AsyncClient() as client:
for c in cases:
hs = await pool.fetch(
"SELECT rule_statement FROM halachot WHERE case_law_id = $1 "
"AND review_status IN ('approved','published','pending_review')", c["id"])
ours = [r["rule_statement"] for r in hs]
user = _bench_user(c["nevo_ratio"], ours)
# panel: claude + deepseek + gemini, take the median 'covered/ratio_points'
async def _claude():
try:
return await claude_session.query_json(user, system=_BENCH_SYSTEM, effort="low")
except Exception:
return None
cj, dj, gj = await asyncio.gather(
_claude(), judge_deepseek(client, _BENCH_SYSTEM, user),
judge_gemini(client, _BENCH_SYSTEM, user))
recalls = []
for v in (cj, dj, gj):
if isinstance(v, dict) and v.get("ratio_points"):
try:
recalls.append(int(v["covered"]) / int(v["ratio_points"]))
except (ValueError, ZeroDivisionError, TypeError):
pass
recall = sorted(recalls)[len(recalls) // 2] if recalls else None
results.append({"case": c["case_number"], "our_halachot": len(ours),
"panel_recall": round(recall, 3) if recall is not None else None})
print(f" {c['case_number']}: ours={len(ours)} recall={results[-1]['panel_recall']}",
flush=True)
measured = [r["panel_recall"] for r in results if r["panel_recall"] is not None]
mean_recall = round(sum(measured) / len(measured), 3) if measured else None
print(f"\nmean ratio-coverage recall (n={len(measured)}): {mean_recall}")
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out = AUDIT / f"nevo-ratio-benchmark-{ts}.json"
out.write_text(json.dumps({"mean_recall": mean_recall, "results": results},
ensure_ascii=False, indent=2))
print(f"report → {out}")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="mode", required=True)
lk = sub.add_parser("leak")
lk.add_argument("--apply", action="store_true",
help="capture nevo_ratio for pre-fix rulings (additive; no re-OCR)")
b = sub.add_parser("benchmark")
b.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
fn = {"leak": run_leak, "benchmark": run_benchmark}[args.mode]
raise SystemExit(asyncio.run(fn(args)))