Files
legal-ai/mcp-server/src/legal_mcp/services/style_distance.py
Chaim f935f166a9
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
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>
2026-06-30 12:13:43 +00:00

271 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""מדד מרחק-סגנון (T7) — האם הטיוטות מתכנסות לדפנה לאורך זמן.
שלושה רכיבים, כולם ללא LLM (דטרמיניסטי, זול):
1. golden_ratio_adherence — סטיית אחוזי-הסעיפים מ-GOLDEN_RATIOS לפי תוצאה.
2. anti_pattern_hits — ספירת אנטי-דפוסים (מ-lessons.ANTI_PATTERNS) בטקסט הטיוטה.
3. draft_to_final_diff — change_percent מ-draft_final_pairs (ככל שיורד → מתכנס).
זהו מטא-אות על בריאות-הלמידה (INV-LRN4) — נצרך ע"י לוח-מחוונים / QA, לא ע"י הכותב.
"""
from __future__ import annotations
import logging
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__)
# block_id → golden-ratio section
_BLOCK_TO_SECTION = {
"block-vav": "background",
"block-zayin": "claims",
"block-yod": "discussion",
"block-yod-alef": "summary",
}
# chunker section_type → golden-ratio section (for corpus measurement, T10)
_CHUNK_SECTION_TO_GOLDEN = {
"facts": "background", "intro": "background",
"appellant_claims": "claims", "respondent_claims": "claims",
"legal_analysis": "discussion",
"conclusion": "summary", "ruling": "summary",
}
_CORPUS_RATIOS_CACHE: dict | None = None
async def measure_corpus_ratios() -> dict:
"""Measure ACTUAL section %-of-total from Dafna's style_corpus, averaged per
outcome — the empirical counterpart to lessons.GOLDEN_RATIOS (T10). Splits each
decision via chunker (accurate, not the filtered exemplars). Cached for the
process. Returns {outcome: {"n": int, "sections": {sec: pct}}}."""
global _CORPUS_RATIOS_CACHE
if _CORPUS_RATIOS_CACHE is not None:
return _CORPUS_RATIOS_CACHE
from legal_mcp.services.chunker import _split_into_sections
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT full_text, outcome FROM style_corpus WHERE full_text <> ''")
# Per-outcome AND an "_all" aggregate. style_corpus.outcome is currently
# unpopulated for the imported corpus, so per-outcome may be empty — "_all"
# is the meaningful signal today, and per-outcome becomes live once outcomes
# are backfilled. No silent loss: callers see which buckets have data via n.
by_outcome: dict[str, list[dict]] = {}
for r in rows:
sect_words: dict[str, int] = {}
for stype, stext in _split_into_sections(r["full_text"]):
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
if g:
sect_words[g] = sect_words.get(g, 0) + len(stext.split())
total = sum(sect_words.values())
if total < 100: # sections didn't parse — skip
continue
pct = {s: w / total * 100 for s, w in sect_words.items()}
by_outcome.setdefault("_all", []).append(pct)
outcome = canonical_outcome(r["outcome"] or "")
if outcome:
by_outcome.setdefault(outcome, []).append(pct)
result: dict = {}
for outcome, decs in by_outcome.items():
avg = {}
for sec in ("background", "claims", "discussion", "summary"):
vals = [d.get(sec, 0.0) for d in decs]
if vals:
avg[sec] = round(sum(vals) / len(vals), 1)
result[outcome] = {"n": len(decs), "sections": avg}
_CORPUS_RATIOS_CACHE = result
return result
def count_anti_patterns(text: str) -> dict:
"""Count each anti-pattern occurrence in text. Lower = closer to Dafna."""
hits = {}
total = 0
for ap in ANTI_PATTERNS:
n = len(re.findall(ap["regex"], text or ""))
if n:
hits[ap["name"]] = {"count": n, "note": ap["note"]}
total += n
return {"total": total, "by_pattern": hits}
def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> dict:
"""% of total per section vs GOLDEN_RATIOS target range. deviation=0 ⇒ within range."""
outcome = canonical_outcome(outcome)
targets = GOLDEN_RATIOS.get(outcome)
total = sum(block_word_counts.values())
if not targets or total == 0:
return {"outcome": outcome, "total_words": total, "sections": {}, "max_deviation": None}
sections = {}
max_dev = 0.0
for block_id, section in _BLOCK_TO_SECTION.items():
if section not in targets:
continue
pct = round(block_word_counts.get(block_id, 0) / total * 100, 1)
lo, hi = targets[section]
if pct < lo:
dev = round(lo - pct, 1)
elif pct > hi:
dev = round(pct - hi, 1)
else:
dev = 0.0
max_dev = max(max_dev, dev)
sections[section] = {"actual_pct": pct, "target": [lo, hi], "deviation_pp": dev}
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)
if not case:
return {"error": f"case {case_number} not found"}
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
outcome = (decision or {}).get("outcome", "rejection")
pool = await db.get_pool()
async with pool.acquire() as conn:
block_rows = []
draft_text = ""
if decision:
block_rows = await conn.fetch(
"SELECT block_id, content, word_count FROM decision_blocks "
"WHERE decision_id = $1 ORDER BY block_index",
UUID(decision["id"]),
)
draft_text = "\n\n".join(b["content"] for b in block_rows if b["content"])
pair = await conn.fetchrow(
"SELECT draft_text, diff_stats, status FROM draft_final_pairs "
"WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1",
case_id,
)
# Prefer the immutable snapshot's draft text when present.
if pair and pair["draft_text"]:
draft_text = pair["draft_text"]
word_counts = {b["block_id"]: (b["word_count"] or 0) for b in block_rows}
ratios = golden_ratio_adherence(word_counts, outcome)
anti = count_anti_patterns(draft_text)
diff = None
if pair and pair["diff_stats"]:
raw = pair["diff_stats"]
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
raw = None
diff = raw
return {
"case_number": case_number,
"outcome": canonical_outcome(outcome),
"golden_ratio_adherence": ratios,
"anti_pattern_hits": anti,
"draft_to_final_diff": diff,
"pair_status": pair["status"] if pair else None,
"summary": {
"ratio_max_deviation_pp": ratios.get("max_deviation"),
"anti_pattern_total": anti["total"],
"change_percent": (diff or {}).get("change_percent") if diff else None,
},
}