125 lines
7.3 KiB
Python
125 lines
7.3 KiB
Python
#!/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()
|