#!/usr/bin/env python3 """Эксперимент 04: бейк-офф редакторов ru-стиля (задача из PROGRESS, вопрос бэкенда №2). Пайплайн: DeepSeek делает ЧЕРНОВИК перевода → каждый редактор-кандидат полирует его в живой литературный русский (чек-лист Норы Галь: убрать канцелярит, кальки, оживить синтаксис), НЕ меняя смысл и не теряя предложений. Выходы сохраняются для сравнения (человеческая оценка владельца — золотой стандарт для худ. качества, LAIT). Запуск: eval/.venv/bin/python eval/editor_bench.py Выход: eval/data/editor_bench//{source,draft,}.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}//*.txt") if __name__ == "__main__": main()