187 lines
11 KiB
Python
187 lines
11 KiB
Python
#!/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)
|