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