#!/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/. 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": , "n_omissions": , "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()