textmachine/eval/pkg7/blind_judge2.py

244 lines
13 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
"""Полигон, пакет-7, фаза B: слепая оценка на ПОЧИНЕННОМ наборе (пре-регистрация §0.3).
Чем отличается от `blind_judge.py` и почему это не «другой судья», а другой НАБОР.
Ревизия §B6 показала: инструмент фазы B оценивал наполовину вырожденные айтемы — у 35 термов из 80
все семь кандидатов были ОДНОЙ И ТОЙ ЖЕ строкой, и судью просили выбрать лучшую из семи одинаковых.
Отсюда плоские счета и «неразличимость армов». Здесь чинится сборка:
1) ДЕДУПЛИКАЦИЯ — одинаковые строки схлопываются в один вариант, арм-источники хранятся в ключе;
2) только термы, где после дедупликации осталось ≥2 варианта (иначе сравнивать нечего);
3) ДЕКОЙ — заведомо неверный вариант (dst ДРУГОГО терма той же выборки), подложенный вслепую.
Декой — несущая часть, а не украшение. В фазе B контроль «отличает ли судья годное от случайного»
был структурно НЕИЗМЕРИМ: строка `GOLD` не была уникальной ни на одном из 19 термов, поэтому
отбраковать судью по ней было нельзя (а отчёт B это сделал — ошибка R1). Декой уникален по
построению, и контроль становится измеримым.
Сырьё пишется вместе с `usage` и `finish_reason` — дефект записи фазы B (§B6 R7.2) чинится здесь.
"""
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 norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").strip().lower())
def salt_order(term_id: str, keys: list[str]) -> list[str]:
h = hashlib.sha256(f"pkg7b2:{term_id}".encode()).hexdigest()
return sorted(keys, key=lambda k: hashlib.sha256((h + k).encode()).hexdigest())
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 {}
raw = m.group(0)
for extra in range(0, 4): # терпимость к недостающим скобкам (урок фазы B)
try:
v = json.loads(raw + "}" * extra)
except json.JSONDecodeError:
continue
if not isinstance(v, dict):
return {}
ver = v.get("verdicts")
if isinstance(ver, dict):
for key in ("catastrophic", "note"):
if key in ver and key not in v:
v[key] = ver.pop(key)
return v
return {}
def build_tasks(items: dict, by_id: dict) -> list[tuple]:
"""Дедупликация вариантов + подкладка декоя. Возвращает задания судье."""
# пул для декоя: по выборке — все dst ДРУГИХ термов
pool: dict[str, list[tuple[str, str]]] = {}
for tid, variants in by_id.items():
it = items.get(tid)
if not it:
continue
for dst in variants.values():
pool.setdefault(it["sample"], []).append((tid, dst))
tasks = []
for tid in sorted(by_id):
it = items.get(tid)
if not it:
continue
# схлопнуть одинаковые строки: ключ — нормализованный текст, значение — (показ, источники)
groups: dict[str, tuple[str, list[str]]] = {}
for src_label, dst in sorted(by_id[tid].items()):
k = norm(dst)
if k not in groups:
groups[k] = (dst, [])
groups[k][1].append(src_label)
if len(groups) < 2:
continue # вырожденный терм — сравнивать нечего
# Декой: dst ДРУГОГО терма той же выборки. Выбор детерминирован солью по терму (прогон
# воспроизводим), первый кандидат, не совпадающий ни с одним настоящим вариантом.
decoy = None
cand_pool = sorted({d for (other, d) in pool.get(it["sample"], []) if other != tid})
if cand_pool:
start = int(hashlib.sha256(f"decoy:{tid}".encode()).hexdigest(), 16) % len(cand_pool)
for k in range(len(cand_pool)):
pick = cand_pool[(start + k) % len(cand_pool)]
if norm(pick) not in groups:
decoy = pick
break
variants = {f"V{n}": g for n, g in enumerate(groups.values())}
if decoy:
variants["DECOY"] = (decoy, ["DECOY"])
crits = [c for c in CRITERIA if not (c == "K2" and it["sample"] in ("S3", "S4"))]
order = salt_order(tid, sorted(variants))
letters = "ABCDEFGH"
cands = [(letters[k], variants[key][0]) for k, key in enumerate(order)]
letter2sources = {letters[k]: variants[key][1] for k, key in enumerate(order)}
tasks.append((tid, it, cands, letter2sources, crits))
return tasks
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--termset", required=True, type=Path)
ap.add_argument("--arms", action="append", required=True, type=Path,
help="можно указать несколько файлов армов (фаза B + арм P5 фазы B)")
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--judge", required=True)
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("--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"))}
by_id: dict[str, dict[str, str]] = {}
for f in a.arms:
for r in json.loads(f.read_text(encoding="utf-8"))["results"]:
if r.get("dst"):
by_id.setdefault(r["id"], {})[r["arm"]] = r["dst"].strip()
for tid, it in items.items():
if it.get("gold") and tid in by_id:
by_id[tid]["GOLD"] = it["gold"].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))
tasks = build_tasks(items, by_id)
ndec = sum(1 for _, _, _, l2s, _ in tasks if any("DECOY" in v for v in l2s.values()))
sizes = [len(c) for _, _, c, _, _ in tasks]
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}: термов {len(tasks)} (из {len(by_id)}); с декоем {ndec}; "
f"вариантов на терм {min(sizes)}{max(sizes)} (в фазе B было ровно 7)")
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, cands, l2s, 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"
elif a.style == "xai":
kw["max_tokens"] = a.max_tokens
kw["reasoning_effort"] = "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)
rows.append({"id": tid, "sample": it["sample"], "judge": a.judge,
"letter2sources": l2s, "candidates": dict(cands),
"verdicts": v.get("verdicts", {}), "catastrophic": v.get("catastrophic", []),
"note": v.get("note", "")})
(a.raw_dir / f"{a.judge}_{n:03d}.json").write_text(
json.dumps({"id": tid, "prompt": prompt, "response": txt,
"usage": {"in": cin, "out": cout}, "finish": resp.choices[0].finish_reason},
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())