textmachine/eval/promptlang/judge_p4.py

277 lines
14 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.

#!/usr/bin/env python3
"""Fidelity-судья на ГОТОВЫХ парах база↔митигация P4 — деливерабл (1) промта сессии, исполненный там,
куда промт и указывал: `~/books/gu-zhenren/research15-probes/`.
Генерация этих пар оплачена 09.07. Здесь покупаются ТОЛЬКО судейские голоса.
Нормы рига D30.10/D39.7, соблюдённые:
· судья ЧУЖОГО семейства к автору перевода (D13.3) — маршрутизация по семейству ПАРЫ:
пары grok-none → судит `deepseek-v4-pro` (кросс-семейно, дешевле grok в 3 раза)
пары deepseek → судит `grok-4.3` (кросс-семейно)
⚠ `deepseek-v4-pro` вендором 31.07 НЕ обновлялся («The DeepSeek-V4-Pro API … unchanged»),
поэтому как прибор он на прежнем поведении — в отличие от flash.
· reasoning ON у обоих (dspro — thinking по умолчанию; grok — дефолт low, `none` НЕ шлём);
· ПОЛНЫЕ окна без обрезки (урок exp15 Q1b);
· ОБА порядка на каждой паре;
· FLOOR-проход на ИДЕНТИЧНЫХ текстах — отдельно для КАЖДОГО судьи;
· пер-голосовой персист + резюм; жёсткий кап расхода; ось — ТОЛЬКО верность.
`--plan` печатает смету по ФАКТИЧЕСКИМ размерам сырья и не делает ни одного вызова.
"""
from __future__ import annotations
import argparse
import collections
import json
import os
import re
import sys
import time
import unicodedata
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
REPO = Path("/home/ubuntu/projects/textmachine")
RAW = Path.home() / "books" / "gu-zhenren" / "research15-probes" / "probes" / "raw"
OUT = Path.home() / "books" / "gu-zhenren" / "promptlang" / "judge_p4"
OUT.mkdir(parents=True, exist_ok=True)
load_dotenv(REPO / "eval" / ".env")
JUDGES = {
# цены — models.yaml, prices_checked 2026-07-10
"dspro": dict(model="deepseek-v4-pro", base="https://api.deepseek.com/v1", key_env="DEEPSEEK_API_KEY",
price_in=0.435, price_out=0.87, max_tokens=16000, reasoning="subset", temperature=0.0),
"grok": dict(model="grok-4.3", base="https://api.x.ai/v1", key_env="XAI_API_KEY",
price_in=1.25, price_out=2.50, max_tokens=8000, reasoning="additive", temperature=0.0),
}
JUDGE_FOR = {"grok": "dspro", "deepseek": "grok"} # семейство ПАРЫ → судья ЧУЖОГО семейства
ARM_NAME = {"G0": "база", "G1": "инстр-после", "G2": "few-shot", "G4": "комбо",
"D0": "база", "D1": "инстр-после", "D2": "few-shot", "DP": "префилл"}
SYS = (
"Ты — контролёр ВЕРНОСТИ художественного перевода с китайского на русский. Тебе дают КИТАЙСКИЙ "
"исходник и ДВА русских перевода одного и того же фрагмента: A и B.\n"
"Оценивай ТОЛЬКО верность: передан ли смысл исходника без ПРОПУСКОВ, ИСКАЖЕНИЙ, ОТСЕБЯТИНЫ и без "
"непереведённых кусков. Гладкость, красота, стиль, выбор синонимов, разбивка на абзацы и длина НЕ "
"оцениваются вовсе — по этим осям любые различия игнорируй.\n"
"Отдельно отмечай ДОБАВЛЕНИЯ, которых нет в исходнике (сноски переводчика, пояснения в скобках, "
"заголовки) — это дефект верности класса addition.\n"
"Дефект засчитывается ТОЛЬКО с дословными цитатами: что в исходнике и что (или ничего) в переводе.\n"
"Если по верности переводы равны — это нормальный и частый ответ: пиши \"tie\".\n"
"Ответь СТРОГИМ JSON одной строкой, без текста вокруг и без markdown:\n"
'{"winner":"A|B|tie","margin":"clear|slight",'
'"defects_A":[{"kind":"omission|distortion|addition|untranslated","zh":"<цитата исходника ≤12 иероглифов>","ru":"<цитата перевода ≤15 слов или пусто>"}],'
'"defects_B":[...],"reason":"<=25 слов"}'
)
def cjk_share(s: str) -> float:
if not s:
return 1.0
return sum(1 for c in s if "CJK UNIFIED" in (unicodedata.name(c, "") or "")) / len(s)
def load_pairs() -> list[dict]:
"""Пары база↔митигация: обе стороны — реальный перевод (не эхо, не пусто). Порядок детерминирован."""
recs = {}
for f in sorted(RAW.glob("echo-*.json")):
d = json.load(open(f))
t = d.get("text") or ""
u = d.get("user") or ""
src = u.split("Переведи следующий фрагмент:\n\n", 1)[-1].split("\n\nНапоминание:")[0]
recs[d["tag"]] = dict(text=t, src=src, echo=cjk_share(t) > 0.15)
groups: dict[tuple[str, str], dict] = collections.defaultdict(dict)
for tag, r in recs.items():
m = re.match(r"echo-grok-(gu-\d+)-(G\d)$", tag)
if m:
groups[("grok", m.group(1))][m.group(2)] = r
continue
m = re.match(r"echo-ds-gu008-(D\w+)-s(\d)$", tag)
if m:
groups[("deepseek", "gu008-s" + m.group(2))][m.group(1)] = r
pairs = []
for (fam, frag), arms in sorted(groups.items()):
bk = "G0" if fam == "grok" else "D0"
base = arms.get(bk)
if not base or base["echo"] or not base["text"]:
continue
for a in ("G1", "G2", "G4", "D1", "D2", "DP"):
m = arms.get(a)
if not m or m["echo"] or not m["text"]:
continue
pairs.append(dict(fam=fam, frag=frag, arm=a, arm_name=ARM_NAME[a],
src=base["src"], base=base["text"], mit=m["text"]))
return pairs
def user_msg(src: str, ta: str, tb: str) -> str:
return (f"=== КИТАЙСКИЙ ИСХОДНИК ===\n{src}\n\n=== ПЕРЕВОД A ===\n{ta}\n\n"
f"=== ПЕРЕВОД B ===\n{tb}\n\nВерни JSON-вердикт по ВЕРНОСТИ.")
def est_tokens(s: str) -> int:
"""Грубая оценка ДЛЯ СМЕТЫ: ханьцы ≈1 ток/симв, кириллица ≈0.4 ток/симв (калибровка exp01)."""
han = sum(1 for c in s if "CJK UNIFIED" in (unicodedata.name(c, "") or ""))
return int(han + (len(s) - han) * 0.4)
class Ledger:
def __init__(self, path: Path, cap: float):
self.path, self.cap, self.total = path, cap, 0.0
if path.exists():
for ln in path.read_text().splitlines():
try:
self.total += json.loads(ln).get("cost_usd", 0.0)
except Exception:
pass
def add(self, rec: dict) -> None:
self.total += rec.get("cost_usd", 0.0)
with open(self.path, "a") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
_clients: dict[str, OpenAI] = {}
def client(jk: str) -> OpenAI:
if jk not in _clients:
cfg = JUDGES[jk]
key = os.environ.get(cfg["key_env"])
if not key:
sys.exit(f"нет ключа {cfg['key_env']}")
_clients[jk] = OpenAI(api_key=key, base_url=cfg["base"], timeout=600)
return _clients[jk]
def call(led: Ledger, jk: str, usr: str, tag: str) -> tuple[str, dict]:
cfg = JUDGES[jk]
if led.total >= led.cap:
sys.exit(f"СТОП: кап ${led.cap} достигнут (потрачено ${led.total:.4f}) — пинг владельцу")
for back in (5, 20, 60, None):
try:
r = client(jk).chat.completions.create(
model=cfg["model"], temperature=cfg["temperature"], max_tokens=cfg["max_tokens"],
messages=[{"role": "system", "content": SYS}, {"role": "user", "content": usr}])
ch = r.choices[0]
txt = (ch.message.content or "").strip()
u = r.usage
pt, ct = u.prompt_tokens or 0, u.completion_tokens or 0
det = getattr(u, "completion_tokens_details", None)
rt = (getattr(det, "reasoning_tokens", 0) if det else 0) or 0
billed = ct + rt if cfg["reasoning"] == "additive" and rt > ct else ct
cost = pt * cfg["price_in"] / 1e6 + billed * cfg["price_out"] / 1e6
rec = dict(tag=tag, judge=cfg["model"], finish=ch.finish_reason, prompt_tok=pt,
completion_tok=ct, reasoning_tok=rt, cost_usd=cost, empty=not txt)
led.add(rec)
if ch.finish_reason != "stop":
print(f"{tag}: finish={ch.finish_reason!r}")
return txt, rec
except Exception as e:
if back is None:
led.add(dict(tag=tag, error=str(e)[:300], cost_usd=0.0))
return "", {"error": str(e)[:300]}
print(f" [retry {tag}] {str(e)[:90]} — сон {back}с")
time.sleep(back)
return "", {}
def parse(txt: str):
if not txt:
return None
s = txt.strip().strip("`")
i, j = s.find("{"), s.rfind("}")
try:
return json.loads(s[i:j + 1]) if i >= 0 and j > i else None
except Exception:
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--plan", action="store_true", help="$0 смета по фактическим размерам сырья")
ap.add_argument("--cap", type=float, help="жёсткий кап расхода, $ (обязателен для прогона)")
ap.add_argument("--only", default="", help="ограничить семейством: grok|deepseek")
a = ap.parse_args()
pairs = load_pairs()
if a.only:
pairs = [p for p in pairs if p["fam"] == a.only]
by_fam = collections.Counter(p["fam"] for p in pairs)
print(f"ПАР база↔митигация: {len(pairs)} · по семействам {dict(by_fam)}")
for p in pairs[:3]:
print(f" пример: {p['fam']}/{p['frag']}/{p['arm_name']} — исходник {len(p['src'])} симв, "
f"база {len(p['base'])}, митигация {len(p['mit'])}")
if a.plan:
total = 0.0
print(f"\n{'семейство':<10}{'судья':<20}{'пар':>5}{'голосов':>9}{'ср.вход ток':>13}{'смета $':>10}")
for fam in sorted(by_fam):
jk = JUDGE_FOR[fam]
cfg = JUDGES[jk]
ps = [p for p in pairs if p["fam"] == fam]
ins = [est_tokens(user_msg(p["src"], p["base"], p["mit"])) + est_tokens(SYS) for p in ps]
avg_in = sum(ins) / len(ins)
out_est = 1600 # вердикт ~400 ток + размышление ~1200 (наблюдение по голосам 31.07)
votes = len(ps) * 2 + 2 # оба порядка + floor-проход на идентичных текстах
cost = votes * (avg_in * cfg["price_in"] / 1e6 + out_est * cfg["price_out"] / 1e6)
total += cost
print(f"{fam:<10}{cfg['model']:<20}{len(ps):>5}{votes:>9}{avg_in:>13.0f}{cost:>10.4f}")
print(f"{'ИТОГО':<10}{'':<20}{len(pairs):>5}{len(pairs)*2+4:>9}{'':>13}{total:>10.4f}")
print("\nдопущения сметы названы: вход — оценка exp01 (хань≈1 ток/симв, кириллица≈0.4),")
print("выход 1600 ток/голос — по 7 фактическим голосам 31.07. Реальный расход пишется в леджер.")
print("ВЫЗОВОВ НЕ СДЕЛАНО.")
return 0
if a.cap is None:
print("для прогона нужен --cap (жёсткий потолок расхода)", file=sys.stderr)
return 2
led = Ledger(OUT / "ledger.jsonl", a.cap)
votes_path = OUT / "votes.jsonl"
seen = set()
if votes_path.exists():
for ln in votes_path.read_text().splitlines():
try:
seen.add(json.loads(ln)["vote_id"])
except Exception:
pass
def vote(vid, kind, p, order, ta, tb, la, lb):
if vid in seen:
return
jk = JUDGE_FOR[p["fam"]]
txt, rec = call(led, jk, user_msg(p["src"], ta, tb), vid)
v = parse(txt) or {}
row = dict(vote_id=vid, kind=kind, judge=JUDGES[jk]["model"], fam=p["fam"], frag=p["frag"],
arm=p["arm"], arm_name=p["arm_name"], order=order, label_A=la, label_B=lb,
winner=v.get("winner"), margin=v.get("margin"), defects_A=v.get("defects_A"),
defects_B=v.get("defects_B"), reason=v.get("reason"), finish=rec.get("finish"),
raw=txt[:1200], cost_usd=rec.get("cost_usd"))
with open(votes_path, "a") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
seen.add(vid)
print(f" {vid}: {v.get('winner')} defБаза={len(v.get('defects_A') or []) if la=='база' else len(v.get('defects_B') or [])} "
f"defМитиг={len(v.get('defects_B') or []) if la=='база' else len(v.get('defects_A') or [])} ${led.total:.4f}")
# FLOOR: идентичные тексты, по одному на семейство → пол шума КАЖДОГО судьи
for fam in sorted(by_fam):
p = next(x for x in pairs if x["fam"] == fam)
for order in ("AB", "BA"):
vote(f"floor::{fam}::{order}", "floor", p, order, p["base"], p["base"], "база", "база")
for p in pairs:
for order in ("AB", "BA"):
ta, tb, la, lb = ((p["base"], p["mit"], "база", p["arm_name"]) if order == "AB"
else (p["mit"], p["base"], p["arm_name"], "база"))
vote(f"{p['fam']}::{p['frag']}::{p['arm']}::{order}", "contrast", p, order, ta, tb, la, lb)
print(f"\nПОТРАЧЕНО: ${led.total:.5f} из капа ${a.cap}; голоса — {votes_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())