textmachine/eval/pkg7/blind_judge.py

191 lines
11 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
"""Полигон, пакет-7, фаза B: слепая оценка Best-Worst Scaling по критериям рубрики §A2.
Почему BWS, а не абсолютные баллы: на художественном материале MQM-скоринг ошибочно признаёт ~60%
ЧЕЛОВЕЧЕСКИХ переводов не лучше машинных, тогда как Best-Worst Scaling опознаёт человека в 80100%
(§A2.0). Абсолютная шкала остаётся только для катастроф-экрана.
Слепота (D13.5 + §A2.4): варианты перемешиваются СОЛЬЮ ПО ТЕРМУ, метки A..F не несут информации об
арме, судья не видит ни имени арма, ни эталона владельца. Порядок сохраняется в ключе отдельно.
Судейская дисциплина (D13.3): судья не судит выход своего семейства — консолидатор deepseek,
судьи из ДРУГИХ семейств. Позиционный своп меряется отдельным прогоном, а не считается бесплатным
(§A2.4 п.5: на кураторских наборах своп может УХУДШАТЬ).
Критерий K2 «жанровая конвенция» на выборках S3/S4 помечен н/д и НЕ оценивается: конвенции для
ja/en у нас нет, оценивать соответствие несуществующему — купить бессмысленный вердикт (§A5.3).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
from pathlib import Path
import yaml
from dotenv import load_dotenv
from openai import OpenAI
REPO = Path(__file__).resolve().parents[2]
load_dotenv(REPO / "eval" / ".env")
CRITERIA = {
"K1": "верность концепту: вариант обозначает то же понятие, что исходный термин в этих контекстах",
"K2": "жанровая конвенция: вариант совпадает с тем, как этот класс терминов принято передавать в русских изданиях жанра",
"K5": "морфологическая пригодность: вариант склоняем по-русски, у него устойчивый род, он не ломает согласование",
"K6": "литературность и благозвучие: вариант годится в художественный текст, не режет ухо",
"K9": "уместность регистра: не подставлен академический/канцелярский термин туда, где нужен жанровый, и наоборот",
}
JUDGE_TASK = """Ты — эксперт по художественному переводу на русский язык. Тебе дают термин
исходного текста, его контексты из книги и НЕСКОЛЬКО вариантов русского соответствия, помеченных
буквами. Кто какой вариант предложил — тебе неизвестно и не важно.
Для каждого названного критерия выбери ЛУЧШИЙ и ХУДШИЙ вариант.
Отдельно отметь варианты, которые являются КАТАСТРОФОЙ: меняют смысл на другой/противоположный,
либо являются не переводом, а мусором.
Отвечай СТРОГО одним JSON-объектом без пояснений вне JSON:
{"verdicts": {"<критерий>": {"best": "<буква>", "worst": "<буква>"}, ...},
"catastrophic": ["<буква>", ...],
"note": "<до 25 слов, почему лучший лучший>"}"""
def salt_order(term_id: str, arms: list[str], reverse: bool) -> list[str]:
h = hashlib.sha256(f"pkg7:{term_id}".encode()).hexdigest()
order = sorted(arms, key=lambda a: hashlib.sha256((h + a).encode()).hexdigest())
return list(reversed(order)) if reverse else order
def build_prompt(item: dict, cands: list[tuple[str, str]], crits: list[str]) -> str:
ctx = "\n".join(f"{k['win']}" for k in item["kwic"][:6]) or " (контекстов нет)"
opts = "\n".join(f" {letter}. {dst}" for letter, dst in cands)
cr = "\n".join(f" {c}: {CRITERIA[c]}" for c in crits)
return (f"{JUDGE_TASK}\n\nИСХОДНЫЙ ТЕРМИН: {item['src']}\n"
f"ЯЗЫК ИСХОДНИКА: {item['lang']}\n\nКОНТЕКСТЫ ИЗ КНИГИ:\n{ctx}\n\n"
f"ВАРИАНТЫ:\n{opts}\n\nКРИТЕРИИ:\n{cr}")
def parse_obj(text: str) -> dict:
m = re.search(r"\{.*\}", text, re.S)
if not m:
return {}
try:
v = json.loads(m.group(0))
return v if isinstance(v, dict) else {}
except json.JSONDecodeError:
return {}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--termset", required=True, type=Path)
ap.add_argument("--arms", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--judge", required=True, help="имя модели из models.yaml")
ap.add_argument("--base-url", required=True)
ap.add_argument("--key-env", required=True)
ap.add_argument("--style", choices=["openai-reasoning", "plain", "xai"], default="plain")
ap.add_argument("--reverse", action="store_true", help="прогон с ОБРАЩЁННЫМ порядком (замер свопа)")
ap.add_argument("--max-tokens", type=int, default=4000)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
items = {i["id"]: i for i in json.loads(a.termset.read_text(encoding="utf-8"))}
arms_data = json.loads(a.arms.read_text(encoding="utf-8"))["results"]
by_id: dict[str, dict[str, str]] = {}
for r in arms_data:
if r.get("dst"):
by_id.setdefault(r["id"], {})[r["arm"]] = r["dst"].strip()
doc = yaml.safe_load((REPO / "backend/configs/models.yaml").read_text(encoding="utf-8")) or {}
p = ((doc.get("models") or {}).get(a.judge) or {}).get("price") or {}
pin, pout = float(p.get("input_per_m", 0)), float(p.get("output_per_m", 0))
# Подписанный владельцем dst участвует в слепом сравнении НАРАВНЕ с армами (§A2.4 п.4):
# без него нельзя вынести F2 («бьёт ли арм эталон»), а оценщик, не отличающий подпись от
# случайного варианта, обязан быть отбракован — увидеть это можно только так.
for tid, it in items.items():
if it.get("gold") and tid in by_id:
by_id[tid]["GOLD"] = it["gold"].strip()
tasks = []
for tid, variants in by_id.items():
it = items.get(tid)
if not it or len(variants) < 2:
continue
crits = [c for c in CRITERIA if not (c == "K2" and it["sample"] in ("S3", "S4"))]
order = salt_order(tid, sorted(variants), a.reverse)
letters = "ABCDEFG"
cands = [(letters[k], variants[arm]) for k, arm in enumerate(order)]
tasks.append((tid, it, order, cands, crits))
est_in = sum(len(build_prompt(it, c, cr)) for _, it, _, c, cr in tasks) / 3.2
est_out = len(tasks) * 350
print(f"ПЛАН судьи {a.judge}{' (обратный порядок)' if a.reverse else ''}: {len(tasks)} термов")
print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k → ${est_in/1e6*pin:.4f}; "
f"выход ≈{est_out/1000:.1f}k → ${est_out/1e6*pout:.4f}; "
f"ИТОГО ≈ ${est_in/1e6*pin + est_out/1e6*pout:.4f}")
if a.dry_run:
return 0
key = os.environ.get(a.key_env)
if not key:
print(f"нет ключа {a.key_env}", file=sys.stderr)
return 2
client = OpenAI(api_key=key, base_url=a.base_url)
a.raw_dir.mkdir(parents=True, exist_ok=True)
rows, ledger = [], []
for n, (tid, it, order, cands, crits) in enumerate(tasks, 1):
prompt = build_prompt(it, cands, crits)
kw: dict = {"model": a.judge, "messages": [{"role": "user", "content": prompt}]}
if a.style == "openai-reasoning":
kw["max_completion_tokens"] = a.max_tokens
kw["reasoning_effort"] = "minimal" # квирк gpt-5-mini
elif a.style == "xai":
kw["max_tokens"] = a.max_tokens
kw["reasoning_effort"] = "low" # квирк grok: дефолт low, явно
else:
kw["max_tokens"] = a.max_tokens
try:
resp = client.chat.completions.create(**kw)
except Exception as e:
print(f"[{n}/{len(tasks)}] {tid} ОШИБКА: {e}", file=sys.stderr)
ledger.append({"id": tid, "error": str(e)})
continue
txt = resp.choices[0].message.content or ""
u = resp.usage
cin, cout = getattr(u, "prompt_tokens", 0), getattr(u, "completion_tokens", 0)
usd = cin / 1e6 * pin + cout / 1e6 * pout
v = parse_obj(txt)
letters = [c[0] for c in cands]
letter2arm = {letters[k]: order[k] for k in range(len(order))}
rows.append({"id": tid, "sample": it["sample"], "judge": a.judge,
"reverse": bool(a.reverse), "letter2arm": letter2arm,
"verdicts": v.get("verdicts", {}), "catastrophic": v.get("catastrophic", []),
"note": v.get("note", "")})
(a.raw_dir / f"{a.judge}_{'rev' if a.reverse else 'fwd'}_{n:03d}.json").write_text(
json.dumps({"id": tid, "prompt": prompt, "response": txt}, ensure_ascii=False, indent=1),
encoding="utf-8")
ledger.append({"id": tid, "in": cin, "out": cout, "usd": usd,
"finish": resp.choices[0].finish_reason, "ok": bool(v.get("verdicts"))})
if n % 10 == 0 or n == len(tasks):
print(f" [{n}/{len(tasks)}] ${sum(x.get('usd',0) for x in ledger):.4f}")
time.sleep(0.2)
a.out.write_text(json.dumps({"rows": rows, "ledger": ledger}, ensure_ascii=False, indent=1),
encoding="utf-8")
tot = sum(x.get("usd", 0) for x in ledger)
ok = sum(1 for r in rows if r["verdicts"])
print(f"\nСУДЬЯ {a.judge}: вердиктов {ok}/{len(tasks)}, ПОТРАЧЕНО ${tot:.5f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())