textmachine/eval/tenant_panel/prompts.py

187 lines
11 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
"""СБОРКА СООБЩЕНИЙ ЭКСП-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"`), чтобы не трогать вызовы паков 2123.
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)