#!/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())