261 lines
18 KiB
Python
261 lines
18 KiB
Python
#!/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)
|