textmachine/eval/dialogue_precision.py

229 lines
13 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""Terse-dialogue precision of the coverage gate (POLYGON package-2 Task 5; closes the untested
dialogue-dense case of the webnovel slice).
WHY: exp07 measured the coverage gate at 0 FP/57 on CLASSIC prose. The webnovel slice was supposed
to close the DIALOGUE-DENSE case (terse quoted replies are the noisiest input for sent_cov/len_ratio)
but did NOT — its dialogue detector counted only lines that START with a quote, while webnovels attribute
dialogue INLINE (方源道:“…”), so dialogue-dense chunks read as prose (<0.5) and never formed a terse
bucket. This harness fixes detection + segmentation and measures the gate's FALSE excision_suspect rate
on GOOD (judged-complete) translations of a ≥20-chunk terse-dialogue bucket from 蛊真人. That rate gates
removing the 1-flag tolerance (D12/Q4) and the acceptance thresholds (exp02/07) for D18.
METHOD:
1. Pull dialogue-dense 节 sections from the book; segment RESPECTING source line breaks (each source
line = a paragraph unit — never glue replies into one blob), group consecutive lines into ~target
chunks. dialogue_density = fraction of lines carrying a QUOTED SPAN (“…”/「…」/『…』), not just a
leading quote. terse = short average quoted-span length.
2. Keep chunks with density >= --min-density → the terse bucket (aim >= 20).
3. Translate each with a reliable, complete translator (default grok non-reasoning: no echo mine).
4. classify_output → excision_suspect (sent_cov<0.75 or len_ratio<corridor-low).
5. Cross-family completeness judge (D13.3) on EVERY chunk → ground truth complete? So:
false_excision = gate flagged excision_suspect BUT judge says complete (no omission).
6. Report: excision rate on terse bucket, false-excision rate, and the split vs prose control.
Full provenance: raw translations + judge JSON + usage + UTC under data/dialogue_precision/raw_<stamp>/.
The book text stays OUT of git (Р8); only counts go to the journal/exp.
Usage:
eval/.venv/bin/python eval/dialogue_precision.py --dry-run # select bucket, no API
eval/.venv/bin/python eval/dialogue_precision.py # full run
"""
from __future__ import annotations
import argparse, json, re, sys, time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
import refusal_bench as rb
BOOK = Path("/home/ubuntu/books/gu-zhenren/guzhenren-utf8.txt")
OUTDIR = ROOT / "data" / "dialogue_precision"
SEC_RE = re.compile(r"^第[0-9一二三四五六七八九十百千零两]{1,10}节")
QUOTE_SPAN = re.compile(r"[“「『][^”」』]*[”」』]") # a quoted span anywhere in the line (inline attribution ok)
PROVIDERS = {p["name"]: p for p in json.loads((ROOT / "providers.json").read_text())["providers"]}
FAMILY = {"deepseek": "deepseek", "grok": "xai", "gemini": "google", "openai": "openai",
"glm": "zhipu", "kimi": "moonshot", "mistral": "mistral"}
TRANSLATE_SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского "
"произведения на русский язык. Сохрани ВСЕ реплики и детали без пропусков и "
"смягчений; стиль — живой литературный русский. Выведи ТОЛЬКО перевод.")
COMPLETE_SYSTEM = (
"Ты — строгий контролёр ПОЛНОТЫ художественного перевода (не стиля). Тебе дают ИСХОДНЫЙ фрагмент "
"(китайский) и его ПЕРЕВОД на русский. Проверь, не ПРОПУЩЕНА ли какая-либо реплика, предложение "
"или значимая деталь (omission). Верни СТРОГО JSON: "
'{"complete": <true|false>, "n_omissions": <int>, "omitted": [<кратко что пропущено>], '
'"notes": "<кратко>"}. complete=true — ничего существенного не пропущено (мелкие стилевые сжатия '
"допустимы); complete=false — есть реальный пропуск реплики/предложения/детали.")
def load_sections() -> list[dict]:
lines = BOOK.read_text(encoding="utf-8").split("\n")
marks = [(i, l.strip()) for i, l in enumerate(lines) if SEC_RE.match(l.strip())]
secs = []
for k, (i, title) in enumerate(marks):
end = marks[k + 1][0] if k + 1 < len(marks) else len(lines)
body = [l.strip() for l in lines[i + 1:end] if l.strip()] # paragraph units = source lines
secs.append({"n": k + 1, "title": title, "lines": body})
return secs
def dialogue_density(chunk_lines: list[str]) -> float:
if not chunk_lines:
return 0.0
d = sum(1 for l in chunk_lines if QUOTE_SPAN.search(l))
return round(d / len(chunk_lines), 3)
def terse_score(chunk_lines: list[str]) -> float:
"""Mean quoted-span length over the chunk's quoted spans (lower = terser)."""
spans = [m.group(0) for l in chunk_lines for m in QUOTE_SPAN.finditer(l)]
return round(sum(len(s) for s in spans) / max(1, len(spans)), 1) if spans else 0.0
def segment_lines(lines: list[str], target: int) -> list[list[str]]:
"""Group consecutive source lines into ~target-char chunks WITHOUT gluing across the line grain."""
chunks, buf, tot = [], [], 0
for l in lines:
if buf and tot + len(l) > target:
chunks.append(buf); buf, tot = [], 0
buf.append(l); tot += len(l)
if buf:
chunks.append(buf)
return chunks
def build_buckets(target: int, min_density: float, want: int) -> tuple[list[dict], list[dict]]:
"""Scan the book, segment, split chunks into terse-dialogue (density>=min) and prose control."""
terse, prose = [], []
for s in load_sections():
for ci, cl in enumerate(segment_lines(s["lines"], target)):
text = "\n".join(cl)
if len(text) < 400:
continue
dens = dialogue_density(cl)
rec = {"id": f"sec{s['n']}#{ci}", "sec": s["n"], "n_lines": len(cl), "chars": len(text),
"dialogue_density": dens, "terse_len": terse_score(cl), "text": text}
(terse if dens >= min_density else prose).append(rec)
terse.sort(key=lambda r: (-r["dialogue_density"], r["terse_len"]))
prose.sort(key=lambda r: r["dialogue_density"])
return terse[:max(want, 20)], prose[:max(want // 2, 10)]
def retry_call(p, system, user, timeout=200, attempts=3):
last = (None, "unknown", {})
for i in range(attempts):
t, err, usage = rb.call_provider(p, system, user, timeout=timeout)
if not err or not re.match(r"(http\|(503|429|500|502|504))|transport\|", err or ""):
return t, err, usage
last = (t, err, usage); time.sleep(2 * (i + 1))
return last
def judge_complete(source, translation, judge_name):
p = PROVIDERS[judge_name]
user = (f"ИСХОДНЫЙ ФРАГМЕНТ (китайский):\n{source}\n\nПЕРЕВОД НА РУССКИЙ:\n{translation}\n\nВерни JSON.")
txt, err, usage = retry_call({"name": judge_name, **p}, COMPLETE_SYSTEM, user, timeout=200)
rec = {"judge_model": p["model"], "usage": usage or {},
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
if err or not txt:
rec["error"] = err or "empty"; return rec
m = re.search(r"\{.*\}", txt, re.S)
if not m:
rec["error"], rec["raw"] = "no-json", txt[:300]; return rec
try:
rec.update(json.loads(m.group(0)))
except json.JSONDecodeError:
rec["error"], rec["raw"] = "bad-json", txt[:300]
return rec
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--translator", default="grok", help="complete translator (no echo mine)")
ap.add_argument("--judge", default="gemini", help="cross-family completeness judge (D13.3)")
ap.add_argument("--target", type=int, default=900)
ap.add_argument("--min-density", type=float, default=0.5)
ap.add_argument("--want", type=int, default=24)
ap.add_argument("--max-prose", type=int, default=10, help="prose control chunks to also run")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if FAMILY.get(args.judge) == FAMILY.get(args.translator):
sys.exit(f"D13.3: judge {args.judge} shares family with translator {args.translator}")
terse, prose = build_buckets(args.target, args.min_density, args.want)
prose = prose[:args.max_prose]
print(f"terse bucket: {len(terse)} chunks (density>={args.min_density}); prose control: {len(prose)}", file=sys.stderr)
print(f" terse density range {terse[-1]['dialogue_density']}..{terse[0]['dialogue_density']} "
f"mean terse_len {round(sum(r['terse_len'] for r in terse)/max(1,len(terse)),1)}", file=sys.stderr)
if args.dry_run:
for r in terse[:24]:
print(f" {r['id']} dens={r['dialogue_density']} terse_len={r['terse_len']} lines={r['n_lines']} chars={r['chars']}", file=sys.stderr)
return
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
raw_dir = OUTDIR / f"raw_{stamp}"; raw_dir.mkdir(parents=True, exist_ok=True)
tp = PROVIDERS[args.translator]
def run_bucket(bucket, label):
rows = []
for r in bucket:
t0 = time.time()
out, err, usage = retry_call({"name": args.translator, **tp}, TRANSLATE_SYSTEM, r["text"], timeout=240)
out = out or ""
verdict = rb.classify_output(r["text"], out, err, rb.EXPECT_LEN_RATIO.get("zh", (2.2, 4.2)))
jr = judge_complete(r["text"], out, args.judge)
complete = jr.get("complete")
flagged = verdict["verdict"] == "excision_suspect"
false_excision = bool(flagged and complete is True)
(raw_dir / f"{label}_{r['id'].replace('#','_')}.txt").write_text(
f"# translator={tp['model']} id={r['id']} density={r['dialogue_density']} verdict={verdict['verdict']}\n"
f"# judge_complete={complete} judge={jr.get('judge_model')} usage={usage}\n"
f"# --- SRC ---\n{r['text']}\n\n# --- TRANSLATION ---\n{out}\n", encoding="utf-8")
row = {**{k: r[k] for k in ("id", "sec", "dialogue_density", "terse_len", "n_lines", "chars")},
"verdict": verdict["verdict"], "sent_cov": verdict.get("sent_cov"),
"len_ratio": verdict.get("len_ratio"), "flagged_excision": flagged,
"judge_complete": complete, "judge_omissions": jr.get("n_omissions"),
"false_excision": false_excision, "judge": jr, "translation": out,
"err": err, "usage": usage, "elapsed_s": round(time.time() - t0, 1)}
rows.append(row)
print(f" [{label}] {r['id']} dens={r['dialogue_density']} verdict={verdict['verdict']} "
f"complete={complete} sent_cov={verdict.get('sent_cov')} len_ratio={verdict.get('len_ratio')} "
f"{'FALSE_EXCISION' if false_excision else ''}", file=sys.stderr)
return rows
terse_rows = run_bucket(terse, "terse")
prose_rows = run_bucket(prose, "prose")
def summarize(rows):
n = len(rows)
flagged = sum(1 for r in rows if r["flagged_excision"])
complete = sum(1 for r in rows if r["judge_complete"] is True)
false_exc = sum(1 for r in rows if r["false_excision"])
judged = sum(1 for r in rows if isinstance(r["judge_complete"], bool))
return {"n": n, "flagged_excision": flagged, "judge_complete": complete, "judged": judged,
"false_excision": false_exc,
"false_excision_rate_of_complete": round(false_exc / max(1, complete), 3),
"excision_rate": round(flagged / max(1, n), 3)}
ts, ps = summarize(terse_rows), summarize(prose_rows)
meta = {"run_utc": stamp, "translator": tp["model"], "judge": PROVIDERS[args.judge]["model"],
"target": args.target, "min_density": args.min_density,
"terse_summary": ts, "prose_summary": ps}
out_path = OUTDIR / f"dialogue_precision_{stamp}.json"
out_path.write_text(json.dumps({"meta": meta, "terse": terse_rows, "prose": prose_rows}, ensure_ascii=False, indent=2))
print(f"\n=== TERSE-DIALOGUE COVERAGE-GATE PRECISION ({ts['n']} chunks) ===", file=sys.stderr)
print(f" terse: excision_flagged={ts['flagged_excision']}/{ts['n']} "
f"judged_complete={ts['judge_complete']}/{ts['judged']} "
f"FALSE_excision={ts['false_excision']} → false-excision-rate-of-complete={ts['false_excision_rate_of_complete']}", file=sys.stderr)
print(f" prose control: excision_flagged={ps['flagged_excision']}/{ps['n']} "
f"FALSE_excision={ps['false_excision']} rate={ps['false_excision_rate_of_complete']}", file=sys.stderr)
print(f" [exp07 classic baseline: 0 FP/57]", file=sys.stderr)
print(f"\nwrote {out_path}\nraw: {raw_dir}", file=sys.stderr)
if __name__ == "__main__":
main()