textmachine/eval/exp14/exp14_regate.py

125 lines
7.3 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
"""exp14 §3 — Fidelity re-gate пере-прогона (D34.3, ОБЯЗАТЕЛЕН; независим от §1-§2).
Span-цитирующий, РАЗДЕЛЬНО стиль/верность, охота на КЛАСС ИНВЕРСИЙ РЕДАКТУРЫ (final vs draft
vs src: билингв-редактор мог купить гладкость ценой смысла), катастроф-скрин терминов.
author≠reviewer: судьи НЕ glm-5 (редактор) и НЕ deepseek (черновик) — кросс-семейно
gpt-5-mini + kimi (+ gemini точечно на флагах). leave-one-out. $ персист.
Вход: rerun/records.json (source/draft/final). ch5.0/ch20.0 — final пуст (экспорт-баг D35.4a),
скорятся отдельно (не в среднем). Выход: exp14/regate_verdicts.jsonl + свод.
Использование: exp14_regate.py [--dry] [--limit N]
"""
from __future__ import annotations
import argparse, json, re, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp14_common as C
RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun")
RECS = json.load(open(RERUN / "records.json"))
JUDGES = ["gpt-5-mini", "kimi-k2.6"] # кросс-семейно к glm-5/deepseek; gemini — точечно вне этого прохода
CAPS = {"gpt-5-mini": 0.06, "kimi-k2.6": 0.10, "gemini-3.1-pro-preview": 0.30}
SYS = ("Ты — билингвальный редактор-эксперт zh→ru, СУДЬЯ ВЕРНОСТИ. Тебе даны: ИСХОДНИК (zh), "
"ЧЕРНОВИК (ru) и ФИНАЛ после редактуры (ru). Оцени ВЕРНОСТЬ ФИНАЛА исходнику РАЗДЕЛЬНО от стиля. "
"Особо ищи КЛАСС ИНВЕРСИЙ РЕДАКТУРЫ: правки, где ФИНАЛ отошёл от смысла исходника ДАЛЬШЕ черновика "
"(редактор внёс смысл, которого в исходнике нет; перевернул полярность/участника/число; выбросил атом). "
"Ответь СТРОГО JSON без markdown: "
'{"fidelity":0-100,"style":0-100,"n_mistranslation":int,"n_omission":int,'
'"n_edit_inversion":int,"catastrophe":true|false,'
'"flags":[{"type":"inversion|omission|term|addition","span_src":"≤12 слов zh",'
'"quote_final":"≤15 слов ru","why":"кратко"}]}. '
"catastrophe=true при инверсии полярности/участника/действия ИЛИ потере ключевого атома (заголовок/событие/термин).")
def run():
ap = argparse.ArgumentParser(); ap.add_argument("--dry", action="store_true")
ap.add_argument("--limit", type=int, default=0); a = ap.parse_args()
sp = C.Spender(C.DIAG / "regate_costs.jsonl", CAPS)
vpath = C.DIAG / "regate_verdicts.jsonl"
done = set(); out = []
if vpath.exists():
for l in vpath.read_text(encoding="utf-8").splitlines():
if l.strip():
v = json.loads(l); out.append(v); done.add((v["chunk"], v["judge"]))
recs = [r for r in RECS if (r["chapter"], r["chunk_idx"]) not in {(5, 0), (20, 0)}
and (r.get("final") or "").strip()]
empties = [(r["chapter"], r["chunk_idx"]) for r in RECS if not (r.get("final") or "").strip()]
if a.limit:
recs = recs[:a.limit]
tasks = [(r, jm) for r in recs for jm in JUDGES
if (f"{r['chapter']}.{r['chunk_idx']}", jm) not in done]
print(f"re-gate: {len(recs)} chunks × {len(JUDGES)} judges = {len(tasks)} new tasks "
f"({len(done)} done); empty-final (экспорт-баг, вне скора): {empties}")
if a.dry:
est = sum(C.predict_cost(jm, 4000, 4000) for _, jm in tasks)
print(f"predicted ≤ ${est:.2f}"); return
for r, jm in tasks:
cid = f"{r['chapter']}.{r['chunk_idx']}"
user = (f"ИСХОДНИК (zh):\n{r['source']}\n\nЧЕРНОВИК (ru):\n{r['draft']}\n\n"
f"ФИНАЛ после редактуры (ru):\n{r['final']}\n\nОцени верность ФИНАЛА. Ответь JSON.")
# gpt-5-mini: reasoning ⊆ completion → нужен запас (medium@4000 съедал бюджет → empty); low@10000
s = C.spec(jm, max_tokens=(16000 if jm == "kimi-k2.6" else 10000),
reasoning_effort=("low" if jm == "gpt-5-mini" else None))
in_est = C.count_tokens(SYS + user, "deepseek")
ok, pred = sp.guard(jm, in_est, s["max_tokens"])
if not ok:
print(f" OVER-CAP {cid} {jm} pred=${pred:.3f}"); continue
text, err, usage = C.call(s, [{"role": "system", "content": SYS},
{"role": "user", "content": user}], timeout=300)
cst = sp.record({"model": jm, "judge": jm, "keyenv": s["keyenv"], "chunk": cid,
"err": err, "usage": usage})
v = parse(text) if not err else {"fidelity": None, "err": err[:60]}
v.update(chunk=cid, judge=jm)
out.append(v)
with open(vpath, "a", encoding="utf-8") as f:
f.write(json.dumps(v, ensure_ascii=False) + "\n")
fl = len(v.get("flags", []) or [])
print(f" {cid:<7} {jm:<12} fid={v.get('fidelity')} style={v.get('style')} "
f"cat={v.get('catastrophe')} flags={fl} ${cst:.4f}")
summarize(out, empties)
def parse(text):
m = re.search(r"\{.*\}", text, re.S)
if not m:
return {"fidelity": None, "parse": text[:80]}
try:
d = json.loads(m.group(0))
return {k: d.get(k) for k in ("fidelity", "style", "n_mistranslation", "n_omission",
"n_edit_inversion", "catastrophe", "flags")}
except Exception:
f = re.search(r'"fidelity"\s*:\s*(\d+)', m.group(0))
cat = '"catastrophe": true' in m.group(0).lower() or '"catastrophe":true' in m.group(0).lower()
return {"fidelity": int(f.group(1)) if f else None, "catastrophe": cat, "parse_fallback": True}
def summarize(out, empties):
from collections import defaultdict
import statistics
byj = defaultdict(list); cats = []; invs = []
for v in out:
if v.get("fidelity") is not None:
byj[v["judge"]].append(v["fidelity"])
if v.get("catastrophe"):
cats.append((v["chunk"], v["judge"]))
for fl in (v.get("flags") or []):
if fl.get("type") == "inversion":
invs.append((v["chunk"], v["judge"], fl.get("quote_final", "")[:60]))
print("\n=== FIDELITY RE-GATE свод ===")
for j, xs in byj.items():
print(f" {j}: mean fidelity {statistics.mean(xs):.1f} (n={len(xs)}, min={min(xs)})")
if len(byj) == 2: # leave-one-out
js = list(byj)
print(f" leave-one-out: без {js[0]}{statistics.mean(byj[js[1]]):.1f}; без {js[1]}{statistics.mean(byj[js[0]]):.1f}")
print(f" КАТАСТРОФ-флаги: {len(cats)}{cats[:20]}")
print(f" ИНВЕРСИИ РЕДАКТУРЫ: {len(invs)}")
for c in invs[:15]:
print(f" {c[0]} [{c[1]}]: {c[2]}")
print(f" экспорт-баг пустые финалы (вне скора): {empties}")
if __name__ == "__main__":
run()