164 lines
9.6 KiB
Python
164 lines
9.6 KiB
Python
#!/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()
|