textmachine/eval/dovodka/promptdiff.py
2026-08-15 17:39:09 +03:00

261 lines
18 KiB
Python
Raw 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
"""ГЕЙТ КОНСИСТЕНТНОСТИ ПАР-ПРОМПТОВ. $0. Ненулевой код = пара не годна к покупкам.
⚠ Зачем. Эксп-19 получил конфаундированный арм: en-зеркало промпта было НЕПОЛНЫМ, и разница
армов смешалась с разницей формулировок. Заказ фазы Д требует «механический гейт консистентности
с zh-промптами (одна структура, отличаются только данные языка; дифф по шаблону — гейт, не
глазами)». Вот он.
ПРАВИЛО, которое гейт проверяет:
пар-промпт обязан быть побайтной копией боевого zh-промпта ВЕЗДЕ, кроме
(а) блока пар-специфики — секции «Единицы и приёмы (общие для <пара>)», это ДАННЫЕ пары;
(б) HTML-комментария провенанса в шапке.
Любое расхождение вне (а)/(б) — НАРУШЕНИЕ: структура промптов разошлась, и контраст между парами
перестал быть контрастом пары.
Дополнительно сверяется то, что дифф строк не ловит:
* множества плейсхолдеров `{{…}}` совпадают;
* разделитель `---USER---` присутствует в обоих и на своём месте;
* последняя инструкция (что выводить) совпадает дословно;
* заголовок пар-блока называет ИМЕННО свою пару.
Запуск: eval/.venv/bin/python eval/dovodka/promptdiff.py [<пара> …] (по умолчанию en-ru)
"""
from __future__ import annotations
import difflib
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
BATTLE = REPO / "backend" / "prompts" / "zh-ru"
PAIRS = REPO / "eval" / "role_topology" / "prompts"
# Пар-блок встречается в ДВУХ формах, и первая редакция гейта знала только одну — из-за этого
# `editor.md` целиком считался «разошедшимся». Форма (1): отдельная строка-заголовок с буллетами
# под ней (`translator.md`). Форма (2): один буллет-абзац (`editor.md`). Ловим обе.
PAIR_HEAD = re.compile(r"^Единицы и приёмы \(общие для ([a-z]{2}→ru)\):\s*$")
PAIR_INLINE = re.compile(r"^-\s*Единицы и приёмы \(общие для ([a-z]{2}→ru)\):")
COMMENT = re.compile(r"(?s)<!--.*?-->\s*")
# ⚠ ВТОРАЯ ЗАКОННО ПАР-СПЕЦИФИЧНАЯ СЕКЦИЯ. Она описывает ТИПОЛОГИЮ ИСХОДНОГО ЯЗЫКА (китайский
# паратаксис против английской ритмической дроби), поэтому расходиться обязана. Но именно внутри
# неё живёт мандат арма, и ровно на его неполноте погорел эксп-19 — поэтому секция не выводится
# из-под гейта целиком: у неё сверяются ЧИСЛО пунктов и мандатные оговорки (ниже).
REFLOW_HEAD = re.compile(r"^ДИСКУРС-ПЕРЕВЁРСТКА\b")
NUMBERED = re.compile(r"^(\d+)\.\s")
# Оговорки, несущие МАНДАТ (а не типологию пары): обязаны стоять в обеих редакциях дословно.
INVARIANTS = (
"Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы",
"число предложений и абзацев совпадать с исходником НЕ обязано",
"Запрещено терять смысловые атомы",
"Каждую новую реплику начинай с нового абзаца через тире",
)
# ⚠ СООТВЕТСТВИЕ ФАЙЛОВ ОБЪЯВЛЯЕТСЯ, А НЕ УГАДЫВАЕТСЯ. Первая редакция подбирала zh-аналог по
# имени с фолбэком на суффикс `-eq` — и сопоставила `translator-reflow` с `translator-reflow-eq`,
# то есть с УРАВНЕННЫМ вариантом мандата эксп-21, который зеркалом не является вовсе. Гейт,
# который сам придумывает, что с чем сравнивать, доказывает не то, что от него хотят.
MAP = {
# en-файл → (zh-оригинал, комментарий провенанса)
"translator": ("backend/prompts/zh-ru/translator.md", "боевой промпт переводчика"),
"editor": ("backend/prompts/zh-ru/editor.md", "боевой промпт редактора"),
# ⚠ Заведён 14.08. Промт сессии требует МЕХАНИЧЕСКОГО гейта консистентности пар-промтов, а
# терминолог в карте отсутствовал — то есть банк-роль новой пары проходила мимо гейта вовсе.
# Гейт при этом честно печатал ПРОВАЛ «файла нет в MAP» и не угадывал, с чем сравнивать.
"terminologist": ("backend/prompts/zh-ru/terminologist.md", "боевой промпт терминолога"),
"translator-reflow": (None, "боевого zh-аналога НЕТ: это вариант полигона, "
"зеркалом боевого промпта не является"),
}
# ⚠ ТРЕТИЙ ЗАКОННО ПАР-СПЕЦИФИЧНЫЙ КЛАСС: примеры. Они обязаны быть НА ИСХОДНОМ ЯЗЫКЕ пары,
# иначе редактор увидит образец не того языка, который правит.
EXAMPLE = re.compile(r"^\[(narrative|dialogue)\b")
# ⚠ ОБЪЯВЛЕННЫЕ РАСХОЖДЕНИЯ. Гейт зелёный ТОЛЬКО на них: любое новое расхождение роняет пару.
# Каждая строка разобрана глазами, причина записана. Так «мирится» дрейф, который иначе накопится
# молча, — и ровно этого не хватило эксп-19.
ALLOWED = {
("en-ru", "editor", "- Сверяй смысл черновика"):
"пример кальки ЗАМЕНЁН своим английским (`he raised an eyebrow` → «поднял бровь» вместо "
"«вскинул брови») — образец обязан быть на исходном языке пары. ⚠ Вставлен в фазе Д: "
"приёмка другого семейства заметила, что НЕСУЩАЯ ось en шла с зеркалом БЕДНЕЕ, чем "
"разведочная ja, и гейт это благословлял",
("en-ru", "editor", "- Вёрстка: собирай повествование"):
"zh добавляет «НЕ копируя построчную разбивку исходника (в оригинале часто одно "
"предложение — одна строка)» — свойство китайской вебновеллы; английская проза так не "
"набирается. Остаток строки совпадает дословно",
("ja-ru", "editor", "- Сверяй смысл черновика"):
"пример кальки ЗАМЕНЁН своим японским (よろしくお願いします как «прошу хорошего "
"обращения») — образец обязан быть на исходном языке пары",
("ja-ru", "editor", "- Вёрстка: собирай повествование"):
"оговорка про построчный набор СОХРАНЕНА и переформулирована под ja: веб-новелла "
"syosetu набирается построчно так же, как китайская",
# ⚠ Терминолог: пример строки ОТВЕТА — тот же третий законный класс, что у редактора, только
# формат другой (три поля через табуляцию, а не маркер `[narrative]`). Термин примера взят
# НЕ из книги среза (проверено: Thibault ×0, 慎二 ×0), чтобы промт не подсказывал модели
# ответы по реальным кандидатам банка.
("en-ru", "terminologist", "师父"):
"пример строки ответа ЗАМЕНЁН своим английским (`Thibault → Тибо`) — образец обязан быть "
"на исходном языке пары; термин вне книги среза, подсказки ответов нет",
# Обе стороны замены объявляются отдельно: дифф показывает и снятую строку, и вставленную.
("en-ru", "terminologist", "Thibault"):
"вставленная половина той же замены примера (см. запись про 师父)",
("ja-ru", "terminologist", "慎二"):
"вставленная половина той же замены примера (см. запись про 师父)",
("ja-ru", "terminologist", "师父"):
"пример строки ответа ЗАМЕНЁН своим японским (`慎二 → Синдзи`, дзи-слог демонстрирует "
"Поливанова против ромадзи) — образец обязан быть на исходном языке пары",
}
# ⚠ ФАЙЛЫ, У КОТОРЫХ ЗЕРКАЛО ПРОСТО НЕ ИМЕЕТ ПАР-БЛОКА. У боевого zh-терминолога его нет: файл
# написан пар-НЕЙТРАЛЬНО, вся языковая специфика приходит подстановками брифа. Пар-версии блок
# ДОБАВЛЯЮТ, и это расхождение объявлено здесь, а не спрятано ослаблением проверки: у пары блок
# обязан быть по-прежнему, требование снимается ТОЛЬКО с zh-стороны. Завести блок в zh нельзя —
# `backend/` чужая зона.
NO_ZH_PAIRBLOCK = {"terminologist"}
BAD = 0
def allowed(pair: str, name: str, line: str) -> str:
for (p, f, pref), why in ALLOWED.items():
if p == pair and f == name and line.startswith(pref):
return why
return ""
def ck(name: str, ok: bool, detail: str = "") -> None:
global BAD # noqa: PLW0603
BAD += not ok
print(f" [{'OK ' if ok else 'ПРОВАЛ'}] {name}" + (f" {detail}" if detail else ""))
def strip(t: str) -> str:
return COMMENT.sub("", t)
def blocks(lines: list[str]) -> tuple[list[str], list[str], list[str], str]:
"""(строки вне пар-секций, строки «Единицы и приёмы», строки ДИСКУРС-секции, объявленная пара)."""
out, pair_lines, reflow, pair, inside, in_ref = [], [], [], "", False, False
for ln in lines:
if REFLOW_HEAD.match(ln):
in_ref, inside = True, False
reflow.append(ln)
continue
if EXAMPLE.match(ln):
reflow.append(ln)
in_ref = False
continue
if in_ref:
if ln.strip() and not NUMBERED.match(ln):
in_ref = False # секция кончилась
else:
reflow.append(ln)
continue
mi = PAIR_INLINE.match(ln)
if mi: # форма (2): один буллет
pair = pair or mi.group(1)
pair_lines.append(ln)
inside = False
continue
m = PAIR_HEAD.match(ln)
if m: # форма (1): заголовок + буллеты
inside, pair = True, m.group(1)
pair_lines.append(ln)
continue
if inside:
if ln.strip() and not ln.startswith("-"):
inside = False # блок кончился
else:
pair_lines.append(ln)
continue
out.append(ln)
return out, pair_lines, reflow, pair
def compare(name: str, zh_p: Path, en_p: Path, want_pair: str, pair: str) -> None:
print(f"\n=== {name}: {zh_p.relative_to(REPO)}{en_p.relative_to(REPO)}")
if not zh_p.exists() or not en_p.exists():
ck("оба файла существуют", False, f"нет {'zh' if not zh_p.exists() else 'en'}")
return
zh, en = strip(zh_p.read_text(encoding="utf-8")), strip(en_p.read_text(encoding="utf-8"))
zl, el = zh.splitlines(), en.splitlines()
zo, zb, zr, zp = blocks(zl)
eo, eb, er, ep = blocks(el)
if name in NO_ZH_PAIRBLOCK:
ck("пар-блок есть у ПАРЫ (у боевого zh его нет — объявлено)", bool(ep),
f"zh «{zp}» · пара «{ep}»")
else:
ck("пар-блок объявлен в обоих", bool(zp) and bool(ep), f"zh «{zp}» · пара «{ep}»")
ck(f"пар-блок называет свою пару ({want_pair})", ep == want_pair, ep or "не найден")
# ГЛАВНАЯ ПРОВЕРКА: всё вне пар-блока обязано совпадать построчно
diff = [d for d in difflib.unified_diff(zo, eo, "zh-ru", want_pair, lineterm="", n=0)
if d[:1] in "+-" and not d.startswith(("---", "+++"))]
undecl = [d for d in diff if not allowed(pair, name, d[1:])]
ck("вне пар-секций НЕОБЪЯВЛЕННЫХ расхождений НЕТ", not undecl,
f"необъявленных строк: {len(undecl)} (всего разошлось {len(diff)})")
for d in undecl[:12]:
print(f" {d[:150]}")
if len(undecl) > 12:
print(f" … ещё {len(undecl) - 12}")
seen = {allowed(pair, name, d[1:]) for d in diff if allowed(pair, name, d[1:])}
for why in sorted(seen):
print(f" · объявленное расхождение: {why}")
zph, eph = set(re.findall(r"\{\{(\w+)\}\}", zh)), set(re.findall(r"\{\{(\w+)\}\}", en))
ck("множества плейсхолдеров совпадают", zph == eph,
f"только в zh: {sorted(zph - eph)} · только в паре: {sorted(eph - zph)}"
if zph != eph else f"{len(zph)} шт.")
ck("разделитель ---USER--- в обоих", ("---USER---" in zh) == ("---USER---" in en) is True,
f"zh {'---USER---' in zh} · пара {'---USER---' in en}")
if "---USER---" in zh and "---USER---" in en:
ck("хвост после ---USER--- совпадает",
zh.split("---USER---", 1)[1].strip() == en.split("---USER---", 1)[1].strip())
# пар-блок ОБЯЗАН отличаться — иначе пара не адаптирована вовсе
def _anon(ls: list[str]) -> list[str]:
return [re.sub(r"[a-z]{2}→ru", "<пара>", x) for x in ls if x.strip()]
ck("пар-блок содержательно ОТЛИЧАЕТСЯ (иначе пара не адаптирована)",
_anon(zb) != _anon(eb), f"строк в блоке: zh {len(zb)} · пара {len(eb)}")
# ── ДИСКУРС-секция: расходиться обязана, но НЕ терять мандат ───────────────────────────────
if zr or er:
zn = [NUMBERED.match(x).group(1) for x in zr if NUMBERED.match(x)]
en_n = [NUMBERED.match(x).group(1) for x in er if NUMBERED.match(x)]
ck("ДИСКУРС-секция есть в обоих", bool(zr) and bool(er),
f"zh {len(zr)} строк · пара {len(er)}")
ck("число пунктов ДИСКУРС-секции совпадает", zn == en_n,
f"zh {zn} · пара {en_n}")
zt, et = "\n".join(zr), "\n".join(er)
miss = [inv for inv in INVARIANTS if (inv in zt) and (inv not in et)]
ck("мандатные оговорки не потеряны в паре", not miss,
"потеряно: " + " · ".join(f"«{x}»" for x in miss) if miss else f"{len(INVARIANTS)} шт.")
extra = [inv for inv in INVARIANTS if (inv in et) and (inv not in zt)]
if extra:
print(f" ⚠ в паре есть оговорка, которой нет в zh: {extra}"
"мандат НЕ уравнен, но в обратную сторону")
def main() -> int:
pairs = [a for a in sys.argv[1:] if not a.startswith("-")] or ["en-ru"]
for p in pairs:
want = f"{p.split('-')[0]}→ru"
print(f"\n{'' * 78}\nПАРА {p}")
d = PAIRS / p
if not d.exists():
ck(f"пар-пакет {p} существует", False, str(d))
continue
for f in sorted(d.glob("*.md")):
if f.stem not in MAP:
ck(f"{f.stem}: соответствие zh-оригиналу ОБЪЯВЛЕНО", False,
"файла нет в MAP — гейт не угадывает, с чем сравнивать")
continue
rel, why = MAP[f.stem]
if rel is None:
print(f"\n=== {f.stem}: сравнение НЕ ПРОВОДИТСЯ — {why}")
continue
compare(f.stem, REPO / rel, f, want, p)
print(f"\n{'ПАР-ПРОМПТЫ КОНСИСТЕНТНЫ' if not BAD else f'НАРУШЕНИЙ: {BAD} — покупки по этой паре ЗАПРЕЩЕНЫ'}")
return BAD
if __name__ == "__main__":
sys.exit(1 if main() else 0)