diff --git a/mcp-server/tests/test_nevo_corpus_audit.py b/mcp-server/tests/test_nevo_corpus_audit.py new file mode 100644 index 0000000..500134b --- /dev/null +++ b/mcp-server/tests/test_nevo_corpus_audit.py @@ -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") diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 24749b3..14e9638 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -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 ` / `--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 המתויג ומודד כל ולידטור (`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`. **חובה מקומי**. | ידני — בדיקה נקודתית | diff --git a/scripts/nevo_corpus_audit.py b/scripts/nevo_corpus_audit.py new file mode 100644 index 0000000..aa07495 --- /dev/null +++ b/scripts/nevo_corpus_audit.py @@ -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)))