textmachine/eval/editor_bench.py

164 lines
9.6 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
"""Эксперимент 04: бейк-офф редакторов ru-стиля (задача из PROGRESS, вопрос бэкенда №2).
Пайплайн: DeepSeek делает ЧЕРНОВИК перевода → каждый редактор-кандидат полирует его
в живой литературный русский (чек-лист Норы Галь: убрать канцелярит, кальки, оживить
синтаксис), НЕ меняя смысл и не теряя предложений. Выходы сохраняются для сравнения
(человеческая оценка владельца — золотой стандарт для худ. качества, LAIT).
Запуск: eval/.venv/bin/python eval/editor_bench.py
Выход: eval/data/editor_bench/<fragment>/{source,draft,<editor>}.txt + results.json
"""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
CJK_RE = re.compile("[一-鿿぀-ヿ]")
def cjk_share(text: str) -> float:
return len(CJK_RE.findall(text)) / max(1, len(text))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from refusal_bench import call_provider, load_env_file # noqa: E402
load_env_file()
ROOT = Path(__file__).resolve().parent
# Черновик — реальный черновой контур пайплайна
DRAFT = {"name": "deepseek", "base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY",
"temperature": 0.3, "max_tokens": 3000}
# Фолбэк-черновик, если основной «эхнул» оригинал (deepseek-chat так делает на плотной zh-прозе)
DRAFT_FALLBACK = {"name": "glm-draft", "base_url": "https://api.z.ai/api/paas/v4",
"model": "glm-4.6", "api_key_env": "ZAI_API_KEY", "temperature": 0.3,
"max_tokens": 4000, "extra_body": {"thinking": {"type": "disabled"}}}
# Редакторы-кандидаты (дорогой тир; имена моделей сверены с /models 2026-07-04)
EDITORS = [
{"name": "glm-4.6", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-4.6",
"api_key_env": "ZAI_API_KEY", "extra_body": {"thinking": {"type": "disabled"}}},
{"name": "kimi-k2.6", "base_url": "https://api.moonshot.ai/v1", "model": "kimi-k2.6",
"api_key_env": "KIMI_API_KEY", "temperature": 1, "max_tokens": 16000},
# Kimi K2.6: только temp=1; ОЧЕНЬ многословная думалка (~11k reasoning-токенов на абзац!) →
# бюджет ≥16000, иначе content пуст (finish=length). Дорогой редактор: reasoning биллится как выход
{"name": "gemini-3.1-pro", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-3.1-pro-preview", "api_key_env": "GEMINI_API_KEY",
"max_tokens": 16000}, # обязательно-думающая: thinking не отключить; на длинном черновике 8k обрезал ответ
{"name": "grok-4.3", "base_url": "https://api.x.ai/v1", "model": "grok-4.3",
"api_key_env": "XAI_API_KEY"},
]
for e in EDITORS:
e.setdefault("temperature", 0.4)
e.setdefault("max_tokens", 4000)
# Фрагменты: (id, lang, файл, срез) — разные исходники и стили.
# wells — en→ru: владелец читает английский, поэтому оценит и стиль, И верность на 100%.
FRAGMENTS = [
("rashomon", "японского", "ja/akutagawa-rashomon.txt", 1400),
("ahq", "китайского", "zh/luxun-ah-q-ch1-4.txt", 1300), # нарративнее zhufu (тот deepseek эхает)
("merosu", "японского", "ja/dazai-hashire-merosu.txt", 1400),
("wells", "английского", "en/wells-time-machine-ch1-2.txt", 1400),
]
# Английский эталон-подстрочник для ja/zh — читаемый владельцем якорь смысла
# (сверить верность русских версий). Для en-исходника не нужен (сам оригинал английский).
ENREF_SYS = ("Translate this fiction fragment from {src} into faithful, literal English. "
"Preserve every sentence and all proper nouns/cultural terms. Output only the translation.")
DRAFT_SYS = ("Ты переводчик. Переведи фрагмент художественной прозы с {src} на русский "
"буквально и точно, сохрани все предложения. Выведи только перевод.")
EDITOR_SYS = (
"Ты — литературный редактор русского художественного перевода уровня издательства. "
"Тебе дают ОРИГИНАЛ (на {src}) и его ЧЕРНОВОЙ подстрочный перевод. Отредактируй "
"русский текст в живую художественную прозу: убери канцелярит и кальки с оригинала, "
"разнообразь синтаксис, добавь естественную русскую идиоматику и ритм — по духу школы "
"Норы Галь. СТРОГО сохрани смысл, все предложения, реалии и имена собственные из черновика. "
"Не добавляй отсебятины. Выведи ТОЛЬКО отредактированный русский текст, без комментариев."
)
def read_frag(rel: str, n: int) -> str:
return (ROOT / "data" / "samples" / rel).read_text()[:n]
def main() -> None:
out_dir = ROOT / "data" / "editor_bench"
out_dir.mkdir(parents=True, exist_ok=True)
results = []
for fid, src_name, rel, n in FRAGMENTS:
fdir = out_dir / fid
fdir.mkdir(exist_ok=True)
source = read_frag(rel, n)
(fdir / "source.txt").write_text(source)
print(f"\n=== {fid} ({src_name}) ===")
# 0. английский эталон смысла (для ja/zh; владелец сверяет верность по нему)
enref_path = fdir / "english_ref.txt"
if src_name != "английского" and not (enref_path.exists() and enref_path.read_text().strip()):
en_src = {"src": src_name.replace("японского", "Japanese").replace("китайского", "Chinese")}
text, err, _ = call_provider(DRAFT, ENREF_SYS.format(**en_src), source, timeout=180)
if text:
enref_path.write_text(text.strip())
print(f" english_ref: {len(text)} симв")
# 1. черновик DeepSeek
draft_path = fdir / "draft.txt"
if draft_path.exists() and draft_path.read_text().strip():
draft = draft_path.read_text()
print(" draft: из кэша")
else:
draft = None
for attempt in range(3): # ретрай на «эхо» (модель вернула оригинал вместо перевода)
t0 = time.time()
cand, err, usage = call_provider(
DRAFT, DRAFT_SYS.format(src=src_name), source, timeout=180)
if not cand:
print(f" draft ОШИБКА: {err[:120]}"); break
if cjk_share(cand) > 0.15:
print(f" draft эхо ({cjk_share(cand):.0%} CJK), ретрай {attempt+1}")
continue
draft = cand
draft_path.write_text(draft)
print(f" draft ({DRAFT['name']}): {time.time()-t0:.0f}с, {len(draft)} симв")
break
if not draft: # основной эхает стабильно → фолбэк-черновик (GLM)
cand, err, _ = call_provider(
DRAFT_FALLBACK, DRAFT_SYS.format(src=src_name), source, timeout=180)
if cand and cjk_share(cand) <= 0.15:
draft = cand
draft_path.write_text(draft)
print(f" draft (фолбэк {DRAFT_FALLBACK['name']}): {len(draft)} симв")
else:
print(" draft не удался даже фолбэком — фрагмент пропущен"); continue
# 2. каждый редактор полирует черновик
user = f"ОРИГИНАЛ ({src_name}):\n{source}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"
for ed in EDITORS:
ep = fdir / f"{ed['name']}.txt"
if ep.exists() and ep.read_text().strip():
print(f" {ed['name']}: из кэша"); continue
t0 = time.time()
text, err, usage = call_provider(ed, EDITOR_SYS.format(src=src_name), user, timeout=300)
if not text:
print(f" {ed['name']}: ОШИБКА/пусто — {err or 'empty'}")
results.append({"fragment": fid, "editor": ed["name"], "error": err or "empty"})
continue
ep.write_text(text.strip())
el = time.time() - t0
results.append({"fragment": fid, "editor": ed["name"], "elapsed_s": round(el, 1),
"chars": len(text), "usage": usage})
print(f" {ed['name']}: {el:.0f}с, {len(text)} симв")
(out_dir / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
print(f"\nГотово. Выходы: {out_dir}/<fragment>/*.txt")
if __name__ == "__main__":
main()