#!/usr/bin/env python3 """СБОРКА СООБЩЕНИЙ ЭКСП-22: боевые промты + бриф МОЕЙ книги + блоки банка. $0. Почему свой модуль, а не `inject_probe.render_translator`. Тот прошит книгой 蛊真人: заголовок, жанр и транскрипция лежат КОНСТАНТАМИ в коде. Для эксп-22 книга другая, и вся книго-специфика обязана быть данными (§0.1 общности: «книжный термин в общем пар-слое = утечка»). Здесь брифы — данные, промты — боевые файлы `backend/prompts/`, а рендер побайтно повторяет `render.go`. Что берётся ДОСЛОВНО и почему: * `translator.md` / `editor.md` — боевые, из `backend/prompts/zh-ru/` (сравнение идёт с тем, что реально поедет в прод); * `HEADER_LAW_PLAIN` — константа эксп-21, байт-идентичная движковому `editor_header`, БЕЗ оговорки области: закон-блок пинится промтом эксп-22 (D39.104); * блок ГЛОССАРИЯ переводчика (банкнота на переводческом проходе) — формат `injection.txt`; * пара en идёт пар-пакетом `role_topology/prompts/en-ru/` — тем же, что мерил эксп-21. """ from __future__ import annotations import json import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) import editor_wire_probe as P # noqa: E402 RT = REPO / "eval" / "role_topology" BATTLE = REPO / "backend" / "prompts" / "zh-ru" PAIR_EN = RT / "prompts" / "en-ru" # ⚠ ПАРА — КЛЮЧ, А НЕ БУЛЕВО. `en: bool` умел ровно две пары и не имел пути для японского, при том # что каталог `prompts/ja-ru/` существует и проходит гейт консистентности. Булево ветвление по паре # в коде запрещено каноном проекта: пара обязана жить в данных. Старая сигнатура сохранена # совместимой (`en=True` ≡ `pair="en-ru"`), чтобы не трогать вызовы паков 21–23. PAIR_DIR = {"zh-ru": BATTLE, "en-ru": PAIR_EN, "ja-ru": RT / "prompts" / "ja-ru"} PAIR_BRIEF = {"zh-ru": "BRIEF_ZH", "en-ru": "BRIEF_EN", "ja-ru": "BRIEF_JA"} USER_SEP = "\n---USER---\n" FEWSHOT_SEP = "\n---FEWSHOT---\n" BANK_FILE = Path.home() / "books" / "tenant-panel" / "bank.json" # Бриф КНИГИ СРЕЗА — данные, не код. Поля те же, что у движка (`render.go` Render). BRIEF_ZH = {"source_lang": "zh", "target_lang": "ru", "genre": "вебновелла", "audience": "взрослые читатели вебновелл", "title": "阴阳天帝诀", "venuti": "0.60", "honorifics": "keep", "transcription": "palladius", "footnotes": "minimal"} BRIEF_EN = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное фэнтези", "audience": "взрослые читатели фэнтези", "title": "Empire of the Dawn", "venuti": "0.60", "honorifics": "keep", "transcription": "practical", "footnotes": "minimal"} # ⚠ БРИФ ЯПОНСКОЙ ПАРЫ — его не существовало, хотя пар-промты `ja-ru` заведены и проходят гейт. # Значения предложены приёмщиком другого модельного семейства при переписи пар-пакета и здесь # зафиксированы как данные пары. `transcription: polivanov` — не косметика: от неё зависит, какую # систему транскрипции промт называет переводчику. BRIEF_JA = {"source_lang": "ja", "target_lang": "ru", "genre": "ранобэ (исэкай)", "audience": "взрослые читатели ранобэ", "title": "円環の果てに ―裏切られた魂、二度目の生―", "venuti": "0.60", "honorifics": "keep", "transcription": "polivanov", "footnotes": "minimal"} GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; " "строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):") def render(tpl: str, brief: dict, text: str, draft: str) -> str: """Зеркало `render.go` Render: ОДИН проход, подставленные значения не пере-сканируются.""" vals = dict(brief, text=text, draft=draft) out, rest = [], tpl while True: i = rest.find("{{") if i < 0: out.append(rest) return "".join(out) j = rest.find("}}", i) if j < 0: out.append(rest) return "".join(out) out.append(rest[:i]) out.append(str(vals.get(rest[i + 2:j].strip(), rest[i:j + 2]))) rest = rest[j + 2:] def load_template(path: Path) -> tuple[str, str, str]: """(системная часть без FEWSHOT, блок FEWSHOT, пользовательская часть). FEWSHOT дропается: в проде `stages[edit].few_shot: false`, и эксп-19/20/21 его тоже дропали — иначе база сравнения не боевая. """ canon = P.strip_comments(path.read_text(encoding="utf-8")) sys_part, user = canon.split(USER_SEP, 1) head = sys_part.split(FEWSHOT_SEP, 1) return head[0].strip(), (head[1].strip() if len(head) == 2 else ""), user.strip() def bank(pair_file: Path | None = None) -> dict: """Банк среза: {термин исходника: {"dst": канон, "rx": регекс формы}}. Пусто — банка нет.""" # ⚠ ЯВНО ЗАПРОШЕННЫЙ банк пары обязан существовать. Тихий `{}` давал `coverage() = (0, 0)`, # все вызыватели стоят за `if n:` — и канон-ось новой пары печаталась бы пустой без единого # слова, а армы «перепис» и «перепис + канон-фиксер» стали бы неразличимы (близнец A4≡A0). # Умолчание (pair_file=None) поведения zh не меняет. if pair_file is not None and not pair_file.exists(): raise SystemExit(f"⛔ банка пары нет: {pair_file} — собрать bank.py --ask/--canon " "ДО покупок, иначе канон-ось пары молча пуста") f = pair_file or BANK_FILE return json.loads(f.read_text(encoding="utf-8"))["terms"] if f.exists() else {} def terms_in(src: str, pair_file: Path | None = None) -> list[str]: """Термы банка, реально встреченные В ЭТОЙ единице — так инъектирует боевой путь (D39.104).""" return [t for t in bank(pair_file) if t in src] def glossary_block(terms: list[str]) -> str | None: """Банкнота переводческого прохода: формат `internal/lang/data/injection.txt`.""" b = bank() if not terms: return None return GLOSSARY_HEADER + "\n" + "\n".join(f"{t} → {b[t]['dst']}" for t in terms) def law_block(terms: list[str], pair_file: Path | None = None) -> str | None: """Закон-блок редактора: заголовок эксп-21 БЕЗ оговорки + строки `- src → «dst»`.""" b = bank(pair_file) if not terms: return None return P.HEADER_LAW_PLAIN + "\n" + "\n".join(f"- {t} → «{b[t]['dst']}»" for t in terms) def translator_msgs(src: str, block: str | None, en: bool = False, pair: str | None = None) -> list[dict]: pair = pair or ("en-ru" if en else "zh-ru") core, _few, user = load_template(PAIR_DIR[pair] / "translator.md") brief = globals()[PAIR_BRIEF[pair]] m = [{"role": "system", "content": render(core, brief, src, "")}] if block: m.append({"role": "system", "content": block}) m.append({"role": "user", "content": render(user, brief, src, "")}) return m def editor_msgs(src: str, draft: str, block: str | None, en: bool = False, pair: str | None = None) -> list[dict]: pair = pair or ("en-ru" if en else "zh-ru") core, _few, user = load_template(PAIR_DIR[pair] / "editor.md") brief = globals()[PAIR_BRIEF[pair]] m = [{"role": "system", "content": render(core, brief, src, draft)}] if block: m.append({"role": "system", "content": block}) m.append({"role": "user", "content": render(user, brief, src, draft)}) return m def selfcheck() -> int: """Проверка исполнением: плейсхолдеров не осталось, книга подставлена, блоки различаются.""" bad = 0 def chk(n: str, ok: bool, d: str = "") -> None: nonlocal bad bad += not ok print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) src, draft = "第一章 试炼\n少年抬头看天。", "Глава первая. Испытание\nЮноша поднял глаза к небу." for en in (False, True): for name, msgs in (("переводчик", translator_msgs(src, None, en)), ("редактор", editor_msgs(src, draft, None, en))): joined = "\n".join(m["content"] for m in msgs) chk(f"{name} {'en' if en else 'zh'}: плейсхолдеров не осталось", "{{" not in joined) title = (BRIEF_EN if en else BRIEF_ZH)["title"] chk(f"{name} {'en' if en else 'zh'}: подставлена книга среза", title in joined) chk(f"{name} {'en' if en else 'zh'}: исходник в сообщении", src in joined) chk("FEWSHOT не уехал в системный промт", "FEWSHOT" not in editor_msgs(src, draft, None)[0]["content"]) b = bank() if b: t = list(b)[:2] law, glo = law_block(t), glossary_block(t) chk("закон-блок несёт заголовок эксп-21 без оговорки", law.startswith(P.HEADER_LAW_PLAIN) and "глоссарий обязателен" not in law) chk("блоки закона и глоссария различаются", law != glo) chk("термы фильтруются по единице", terms_in("нет иероглифов") == []) else: print("[ПРОП ] банк ещё не собран — блоки не проверены") print(f"\n{'СБОРКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}") return bad if __name__ == "__main__": sys.exit(1 if selfcheck() else 0)