fix(calibration): drop block-yod + per-cell guard + incremental write (#208)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

A live calibration run crashed with no error handling and lost ~2h of work:
the per-cell loop in _run() reached block-yod (discussion), block_writer.write_block
raised ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…") because the discussion
block needs an approved direction calibration cases don't have, and since the report
was only written at the very end of main(), every completed generation was lost.

Three robustness fixes (no metric/scoring change — G2):

1. Drop block-yod from default CALIBRATABLE_BLOCKS — out of WS5 interim-draft scope
   AND structurally non-calibratable standalone (needs brainstorm→approve_direction).
   Kept in new VALID_BLOCKS so a user can still force it via --blocks block-yod.
2. Per-cell try/except in _run(): any single (case, block, effort) cell failure is
   logged as a warning (case/block/effort/error) and skipped, never fatal —
   defense-in-depth for a forced block-yod and for transient rate-limits mid-run.
3. Incremental persistence: _write_report flushes after each completed block (ts
   threaded into _run), so a crash later in the grid never loses finished blocks.
   JSON/MD shape unchanged — partial results omit not-yet-done blocks from by_block.

--self-test: ALL PASS (14/14). block-yod gone from --dry-run defaults.

Invariants — נוגע / מקיים:
- G8 (eval-harness robustness) — partial results persist; a single-cell failure is
  non-fatal; the harness survives block-yod's missing-direction raise and rate-limits.
- G2 (מקור-מדידה יחיד) — no metric-path change; _score_cell / block_distance_to_final
  / recommend_effort / aggregate_cell untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:49:15 +00:00
parent c6d20654a9
commit 70a06c3745
2 changed files with 42 additions and 12 deletions

View File

@@ -76,7 +76,7 @@
| `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) |
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)``claude_session.query``claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-<ts>.{json,md}`. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח את לוגיקת-המדידה) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)``claude_session.query``claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח את לוגיקת-המדידה) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |

View File

@@ -55,12 +55,15 @@ from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from statistics import mean
logger = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
@@ -77,9 +80,14 @@ OUT_DIR = REPO_ROOT / "data" / "eval"
# Only the AI blocks that map to a golden-ratio section can be scored against the
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
# deterministic template-fill — no effort knob. block-yod (discussion) is out of
# WS5's interim scope but mappable, so it's included when present.
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod", "block-yod-alef"]
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
# that calibration cases lack, so block_writer.write_block raises and it is NOT
# calibratable standalone — and it's out of WS5's interim-draft scope. It stays in
# VALID_BLOCKS so a user can still force it (--blocks block-yod) at their own risk;
# the per-cell guard in _run() keeps such a failure non-fatal.
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod-alef"]
VALID_BLOCKS = CALIBRATABLE_BLOCKS + ["block-yod"]
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
@@ -228,7 +236,7 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
return agg
async def _run(args) -> dict:
async def _run(args, ts: str) -> dict:
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import canonical_outcome
@@ -279,10 +287,21 @@ async def _run(args) -> dict:
final_section = c["sections"][section]
case_cells = []
for effort in efforts:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
)
# Per-cell guard: ANY failure of a single (case, block, effort) cell
# — e.g. block-yod raising "ללא כיוון מאושר", or a transient rate-limit
# mid-run — is logged and skipped, NOT allowed to kill the whole run
# (INV-G8 eval-harness robustness). Partial results still persist below.
try:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
)
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
logger.warning(
"calibration cell skipped: case=%s block=%s effort=%s%s",
c["case_number"], block_id, effort, exc,
)
continue
per_effort_runs[effort].append(cell)
case_cells.append({k: cell[k] for k in
("effort", "distance", "anti_pattern_total",
@@ -313,6 +332,15 @@ async def _run(args) -> dict:
"per_case": per_case,
}
# Incremental persistence (INV-G8): flush the report after every completed
# block so a crash later in the grid never loses blocks already generated.
# Blocks not yet done are simply absent from by_block; _write_report tolerates
# partial results. main() does the final flush once the loop finishes.
try:
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
logger.warning("incremental report write failed after block=%s%s", block_id, exc)
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
@@ -383,6 +411,8 @@ async def main() -> int:
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if args.self_test:
return _self_test()
@@ -392,13 +422,13 @@ async def main() -> int:
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
return 2
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
bad_b = [b for b in args.blocks if b not in CALIBRATABLE_BLOCKS]
bad_b = [b for b in args.blocks if b not in VALID_BLOCKS]
if bad_b:
print(f"non-calibratable block(s): {bad_b}. valid: {CALIBRATABLE_BLOCKS}", file=sys.stderr)
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
return 2
result = await _run(args)
ts = _ts()
result = await _run(args, ts)
jp, mp = _write_report(result, ts)
g = result["grid"]