#!/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)