feat(calibration): כיול-אמפירי model×effort מול הסופיים (#208)
A/B harness שמכייל את ה-effort הנעוץ per-בלוק (#204) מול הסופיים של דפנה: מייצר מחדש כל בלוק דרך מסלול-הייצור (write_block(effort_override=…) → claude_session.query → claude -p, Opus 4.8, מקומי-בלבד) ומודד מול הסקשן המתאים בסופי דרך style_distance.block_distance_to_final (change_percent, anti_pattern_total, golden-ratio deviation, composite distance). ממליץ per-בלוק על ה-effort הקרוב-ביותר לסופי. - scripts/calibrate_effort.py — ההארנס (מודל eval_retrieval.py): --self-test (offline, מוכיח מדידה+המלצה, אפס DB/CLI) · --dry-run · --efforts/--blocks/ --case/--repeats. דוח data/eval/effort-calibration-<ts>.{json,md} עם גודל-מדגם בולט — עדות-כיוון, לא רגרסיה (מעט סופיים-עלויים). - style_distance.py — block_distance_to_final + split_final_by_section (מקור-מדידה יחיד, G2; reuse compute_diff_stats/count_anti_patterns/chunker). - block_writer.py — write_block(effort_override=) להזרקת effort per-קריאה בלי לדרוס את ברירות-המחדל הנעוצות; רושם את ה-effort האפקטיבי. - scripts/SCRIPTS.md — ערך חדש. Invariants: G8 (eval-harness — מדידה אמפירית, לא הנחה) · G2 (reuse של style_distance/learning_loop — אין מסלול-מדד מקביל) · claude_session local-only (reference_claude_generation_path) · INV-LRN4/5 (השוואה מול הסופי, מדידת-סגנון; אין מהות-תיק נגררת). אומת: --self-test 14/14 PASS. הריצה החיה host-only (claude CLI) — לא ניתנת-להרצה ב-worktree/קונטיינר. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -355,6 +355,7 @@ async def write_block(
|
||||
case_id: UUID,
|
||||
block_id: str,
|
||||
instructions: str = "",
|
||||
effort_override: str | None = None,
|
||||
) -> dict:
|
||||
"""כתיבת בלוק יחיד בהחלטה.
|
||||
|
||||
@@ -362,6 +363,11 @@ async def write_block(
|
||||
case_id: מזהה התיק
|
||||
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
|
||||
instructions: הנחיות נוספות
|
||||
effort_override: optional per-call reasoning effort (low/medium/high/
|
||||
xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for
|
||||
THIS call only — used by the #208 model/effort calibration harness
|
||||
to A/B efforts without mutating the pinned defaults. Production
|
||||
callers leave it None and get the deterministic per-block effort.
|
||||
|
||||
Returns:
|
||||
dict עם content, word_count, block_id, generation_type
|
||||
@@ -472,7 +478,7 @@ async def write_block(
|
||||
# reasoning effort so generation is structurally deterministic — these were
|
||||
# previously NOT forwarded (the source of inconsistency). model/effort flow
|
||||
# through claude_session.query → `claude -p --model … --effort …`.
|
||||
effort = block_cfg.get("effort", DEFAULT_EFFORT)
|
||||
effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT)
|
||||
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
|
||||
content = await claude_session.query(
|
||||
prompt,
|
||||
@@ -485,6 +491,10 @@ async def write_block(
|
||||
sources = await _collect_block_sources(case_id, block_id)
|
||||
sources["case_law_ids"] = _precedent_case_law_ids
|
||||
result = _build_result(block_id, content, block_cfg)
|
||||
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
||||
# the measured distance to the effort that actually produced the text.
|
||||
if result.get("effort") is not None:
|
||||
result["effort"] = effort
|
||||
result["sources"] = sources
|
||||
return result
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import re
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.learning_loop import compute_diff_stats
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -122,6 +123,93 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d
|
||||
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
|
||||
|
||||
|
||||
def split_final_by_section(final_text: str) -> dict[str, str]:
|
||||
"""Group a signed final decision into golden-ratio sections (#208 calibration).
|
||||
|
||||
Reuses the SAME structure-aware splitter as measure_corpus_ratios
|
||||
(chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel
|
||||
parsing path (G2). Returns {golden_section: concatenated_text} for the
|
||||
sections that map to an AI block (background/claims/discussion/summary).
|
||||
A section type that does not map (e.g. headers) is dropped, never silently
|
||||
folded into another section. Best-effort: an unsplittable final returns {}.
|
||||
"""
|
||||
from legal_mcp.services.chunker import _split_into_sections
|
||||
|
||||
by_section: dict[str, list[str]] = {}
|
||||
for stype, stext in _split_into_sections(final_text or ""):
|
||||
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
|
||||
if g and stext.strip():
|
||||
by_section.setdefault(g, []).append(stext.strip())
|
||||
return {sec: "\n\n".join(parts) for sec, parts in by_section.items()}
|
||||
|
||||
|
||||
def block_distance_to_final(
|
||||
block_id: str,
|
||||
regenerated_text: str,
|
||||
final_section_text: str,
|
||||
outcome: str,
|
||||
section_target_total_words: int | None = None,
|
||||
) -> dict:
|
||||
"""Distance of ONE regenerated block from the chair's matching final section.
|
||||
|
||||
The per-(block, effort) measurement cell for the #208 model/effort
|
||||
calibration harness. Pure/deterministic (no LLM, no DB) — reuses the
|
||||
existing style-distance primitives so the harness has no parallel metric
|
||||
path (G2 / INV-G8 eval-harness):
|
||||
|
||||
• change_percent — compute_diff_stats(regen, final_section)
|
||||
(learning_loop, the SAME diff the pairing
|
||||
ledger stores). Lower ⇒ the draft already
|
||||
reads like the final ⇒ less chair rewriting.
|
||||
• anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS).
|
||||
Lower ⇒ closer to Dafna's continuous-narrative
|
||||
voice; the CLEANEST style signal (07-learning §0.7).
|
||||
• golden_ratio_deviation_pp — |regen %-of-total − final %-of-total| for this
|
||||
block's section. 0 ⇒ same structural weight as
|
||||
the final. Requires the final's total words
|
||||
(section_target_total_words); otherwise None
|
||||
(we never fabricate a denominator).
|
||||
|
||||
Returns the three metrics + a single composite `distance` (normalized,
|
||||
lower=closer) the harness ranks efforts by.
|
||||
"""
|
||||
outcome = canonical_outcome(outcome)
|
||||
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
|
||||
change_percent = diff["change_percent"]
|
||||
anti_total = count_anti_patterns(regenerated_text or "")["total"]
|
||||
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
regen_words = len((regenerated_text or "").split())
|
||||
final_words = len((final_section_text or "").split())
|
||||
ratio_dev: float | None = None
|
||||
if section and section_target_total_words and section_target_total_words > 0:
|
||||
# Replace the final's own block contribution with the regen's, holding
|
||||
# the rest of the final constant, to compare structural weight fairly.
|
||||
regen_total = section_target_total_words - final_words + regen_words
|
||||
if regen_total > 0:
|
||||
regen_pct = regen_words / regen_total * 100
|
||||
final_pct = final_words / section_target_total_words * 100
|
||||
ratio_dev = round(abs(regen_pct - final_pct), 1)
|
||||
|
||||
# Composite: normalize each component to ~[0,1] and average the present ones.
|
||||
# change_percent/100, anti_total/10 (10+ hits is already very bad), ratio/20.
|
||||
comps: list[float] = [min(change_percent / 100.0, 1.0), min(anti_total / 10.0, 1.0)]
|
||||
if ratio_dev is not None:
|
||||
comps.append(min(ratio_dev / 20.0, 1.0))
|
||||
distance = round(sum(comps) / len(comps), 4)
|
||||
|
||||
return {
|
||||
"block_id": block_id,
|
||||
"section": section,
|
||||
"regen_words": regen_words,
|
||||
"final_words": final_words,
|
||||
"change_percent": change_percent,
|
||||
"anti_pattern_total": anti_total,
|
||||
"golden_ratio_deviation_pp": ratio_dev,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
|
||||
async def style_distance(case_number: str) -> dict:
|
||||
"""Assemble the 3 style-distance components for one case (T7)."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
|
||||
Reference in New Issue
Block a user