597 lines
45 KiB
Python
597 lines
45 KiB
Python
#!/usr/bin/env python3
|
||
"""ОДНОПРОХОДКА КАК РОЛЬ — новый промт против прежней склейки, на нескольких моделях. Платный.
|
||
|
||
⚠ ЗАЧЕМ. Однопроходка — единственный живой продуктовый кандидат из всего, что дуга 19→23
|
||
измерила: по судейской оси от боевой связки не отличается, по банку терминов ЛУЧШЕ неё на обеих
|
||
парах (потерянных терминов на главу 1.38 против 2.88 на zh, 0.31 против 0.56 на en), стоит ОДИН
|
||
вызов вместо двух, и при слепом чтении владелец поставил её ПЕРВОЙ на английском.
|
||
|
||
⚠⚠ И всё это она делает С ГАНДИКАПОМ. Её промт (`contour.a2_msgs`) — не продуктовый, а
|
||
ИЗМЕРИТЕЛЬНЫЙ: боевой промт переводчика плюс мандатная часть боевого промта редактора,
|
||
механически склеенные (четыре строки выброшены, четыре подставлены), с прямой мета-фразой про наш
|
||
конвейер — «отдельного редактора после тебя НЕ БУДЕТ». Склейка сделана по уважительной причине:
|
||
в эксп-21 у однопроходки системный промт был вдвое короче, и замеренная разница могла быть
|
||
разницей ОБЪЁМА ИНСТРУКЦИИ, а не топологии. Уравнивание было правильным ДЛЯ ЗАМЕРА — но
|
||
следствие в том, что однопроходка ни разу не испытывалась как нормально написанный промт-роль.
|
||
|
||
ЧТО ПОКУПАЕТСЯ (имена армов ОДНИ на обе пары, пара живёт в данных):
|
||
|
||
ZP ПРЕЖНЯЯ СКЛЕЙКА, deepseek-v4-pro БЕСПЛАТНО, куплена заходом Д17 — это база
|
||
Z0 боевая связка (черновик → перепис) БЕСПЛАТНО — эталон продакшена
|
||
ZF вторая генерация связки БЕСПЛАТНО — пол СТАРОЙ панели
|
||
ZD голый черновик БЕСПЛАТНО — якорь низа
|
||
RN НОВАЯ РОЛЬ, deepseek-v4-pro, генерация 1 покупается — несущий арм
|
||
RN2 НОВАЯ РОЛЬ, deepseek-v4-pro, генерация 2 покупается — СВОЙ пол новой панели
|
||
RG НОВАЯ РОЛЬ, glm-5 покупается — переносимость, другой вендор
|
||
RK НОВАЯ РОЛЬ, grok-4.3 покупается — переносимость, третья семья
|
||
|
||
⚠ ПЕРВИЧНЫЙ КОНТРАСТ ОДИН: `RN/ZP` — новый промт против прежней склейки НА ТОЙ ЖЕ МОДЕЛИ и ТЕХ ЖЕ
|
||
главах. Всё остальное описательное. Одного контраста требует мощность: при n=16 и поправке Холма
|
||
по четырём контрастам MDE вырос бы выше цели, и ось не дала бы вывода ни при каком исходе — ровно
|
||
та болезнь, что погубила английскую ось эксп-22.
|
||
|
||
⚠ ТРЕТЬЯ МОДЕЛЬ — `grok-4.3`, и выбор не случаен. Главная претензия владельца при слепом чтении
|
||
была РЕГИСТР: «смягчённый мат там, где на мате держится сцена». xAI — единственный вендор, у
|
||
которого слова `violen*` в правилах нет вовсе, и модель меньше прочих склонна смягчать.
|
||
⚠ `gemini` в панель НЕ берётся: на этом материале он массово падает в контент-фильтр (эксп-21:
|
||
10 клеток судейского прогона с `PROHIBITED_CONTENT`), и его пустые клетки мерили бы фильтр, а не
|
||
промт. `grok-4.3` идёт с ВКЛЮЧЁННЫМ размышлением (вендор-дефолт `low`): запрет D30.1 прямой —
|
||
grok с reasoning-off в редакторской роли есть no-op-редактор.
|
||
|
||
Запуск: rol.py --show [zh|en] | --dry | --selftest | --buy [zh|en] [армы…] | --canon
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
import statistics as st
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||
ZONE = Path(__file__).resolve().parent
|
||
for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"):
|
||
sys.path.insert(0, str(REPO / "eval" / d))
|
||
|
||
|
||
def _load(name: str, path: Path):
|
||
spec = importlib.util.spec_from_file_location(name, path)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
sys.modules[name] = mod
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
# ⚠ Контур берётся У ДРАЙВЕРА английской оси, а не грузится вторым экземпляром: `en_axis` держит
|
||
# СВОЙ `contour.py`, и `wire()` настраивает хуки именно в нём. Свой второй экземпляр остался бы с
|
||
# китайскими хуками, и английская ось молча стала бы пустой. Класс уже стоил фазе оплаченных клеток.
|
||
EN = _load("en_axis_r", ZONE / "en_axis.py")
|
||
C = EN.C
|
||
MONEY, PR, ROSTER, ED22, PARA = C.MONEY, C.PR, C.ROSTER, C.ED22, C.PARA
|
||
OUT = MONEY.OUT
|
||
ZK = _load("zakhod_r", ZONE / "zakhod.py") # гейт перелома цены, пред-полёт, отбор глав
|
||
|
||
CORE = ZONE / "prompts" / "onepass-core.md"
|
||
MAX_OUT = 32000 # как в заходе Д17: обрыв на потолке стоит денег и не даёт данных
|
||
|
||
# ── ПАРА КАК ДАННЫЕ. Ни одна ветка ниже не спрашивает «а это китайский?» ──────────────────────
|
||
PAIRS = {
|
||
"zh": {"letter": "m", "phase": "ФД-M", "onepass": "A2",
|
||
"wire": lambda: C.configure(PARA.ZH), "units": lambda: C.units_ok(),
|
||
"para": lambda: PARA.ZH},
|
||
"en": {"letter": "n", "phase": "ФД-N", "onepass": "E2",
|
||
"wire": EN.wire, "units": EN.units, "para": lambda: PARA.EN},
|
||
}
|
||
# ── ВОЗВРАТ АВТОРСКИХ АБЗАЦЕВ ИСХОДНИКУ ──────────────────────────────────────────────────────
|
||
# ⚠⚠ ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ АРМ, А НЕ ПОЧИНКА ЭКСТРАКТОРА. Наш английский экстрактор
|
||
# (`pair_en.raw_text`) сносит из epub ВСЕ теги регексом, включая `</p>`: в книге 11 447 абзацев
|
||
# медианой по 62 знака, а модели едет один сплошной блок. Это наш баг, и чинить его надо.
|
||
# НО uid единицы есть `sha1(source.strip())` (`bakeoff.uid_of`), то есть ЛЮБАЯ правка пробелов
|
||
# переименовывает все единицы. Цена такой правки замерена: **289 оплаченных клеток на $2.64**
|
||
# теряют адресата, плюс семь файлов слепых ключей и все судейские ответы английской пары.
|
||
# Манифест это поймает и остановит покупку — гейт работает, — но история оси всё равно умрёт.
|
||
#
|
||
# ⇒ Починка делается НА ПОДАЧЕ: тот же текст той же единицы, с тем же uid, но с возвращёнными
|
||
# границами абзацев. Функция взята у `podacha.py`, а не написана второй раз.
|
||
# ⇒ И это ОТДЕЛЬНЫЙ АРМ, потому что иначе замер стал бы некорректен: если `RN` получит
|
||
# восстановленный исходник, а его база `ZP` — плоский, контраст смешает эффект ПРОМТА с эффектом
|
||
# ПОДАЧИ, и разделить их будет нечем. Поэтому:
|
||
# RN / ZP — эффект ПРОМТА (вход у обоих ОДИНАКОВЫЙ, плоский)
|
||
# RA / RN — эффект ПОДАЧИ (промт у обоих ОДИНАКОВЫЙ, новый)
|
||
# ⚠ У китайской пары арма `RA` нет и быть не может: её исходник абзацы не терял (78 строк по
|
||
# 24 знака), возвращать нечего, и клетка была бы побайтным близнецом `RN`.
|
||
POD = _load("podacha_r", ZONE / "podacha.py")
|
||
|
||
|
||
def _src_en(src: str) -> str:
|
||
r = POD.restore_paragraphs(src, POD.EPUB_EN)
|
||
if not r.strip():
|
||
raise SystemExit("⛔ авторские абзацы для единицы не нашлись — арм RA стал бы близнецом "
|
||
"RN, покупка отказана")
|
||
return r
|
||
|
||
|
||
PAIRS["en"]["restore"] = _src_en
|
||
PAIRS["zh"]["restore"] = None
|
||
|
||
# арм → (модель, номер генерации, подача исходника). Генерация нужна, чтобы два прогона ОДНОЙ
|
||
# модели не схлопнулись в один тег: кэш `purchase` смотрит на существование файла и вернул бы
|
||
# первую клетку второй раз.
|
||
ARMS = {"RN": ("deepseek-v4-pro", 1), "RN2": ("deepseek-v4-pro", 2),
|
||
"RG": ("glm-5", 1), "RK": ("grok-4.3", 1),
|
||
"RA": ("deepseek-v4-pro", 1)} # тот же промт, ВОССТАНОВЛЕННЫЕ абзацы исходника
|
||
RESTORE_ARMS = ("RA",)
|
||
FREE = ("ZD", "Z0", "ZP")
|
||
PRIMARY = ("RN", "ZP") # единственный несущий контраст, см. шапку
|
||
|
||
# ── СБОРКА ПРОМТА ────────────────────────────────────────────────────────────────────────────
|
||
# ⚠ ПАР-БЛОКИ НЕ ДУБЛИРУЮТСЯ, А ПОДТЯГИВАЮТСЯ ИЗ ФАЙЛОВ САМОЙ ПАРЫ. Второй экземпляр «Единиц и
|
||
# приёмов» разъехался бы с боевым молча, и замер мерил бы промт, которого в бою нет. Гейт
|
||
# пар-промтов сторожит те файлы, а не мою копию, — значит копии быть не должно.
|
||
UNITS_HEAD = "Единицы и приёмы"
|
||
REFLOW_HEAD = "ДИСКУРС-ПЕРЕВЁРСТКА"
|
||
|
||
|
||
def _block(core: str, head: str) -> str:
|
||
"""Секция от заголовка до пустой строки перед следующим заголовком верхнего уровня."""
|
||
i = core.find(head)
|
||
if i < 0:
|
||
return ""
|
||
tail = core[i:]
|
||
# секция кончается там, где начинается строка «Выведи ТОЛЬКО» или следующий ЗАГОЛОВОК КАПСОМ
|
||
stop = len(tail)
|
||
for mark in ("\nВыведи ТОЛЬКО", "\nХонорифики:"):
|
||
j = tail.find(mark)
|
||
if 0 <= j < stop:
|
||
stop = j
|
||
return tail[:stop].strip()
|
||
|
||
|
||
def pair_blocks(p: str) -> tuple[str, str]:
|
||
"""(«Единицы и приёмы» пары, «ДИСКУРС-ПЕРЕВЁРСТКА» пары) — из её же боевых файлов."""
|
||
d = PR.PAIR_DIR[PAIRS[p]["para"]().key]
|
||
tr_core, _f, _u = PR.load_template(d / "translator.md")
|
||
ed_core, _f2, _u2 = PR.load_template(d / "editor.md")
|
||
return _block(tr_core, UNITS_HEAD), _block(ed_core, REFLOW_HEAD)
|
||
|
||
|
||
def rol_msgs(p: str, src: str) -> list[dict]:
|
||
"""Однопроходка НОВЫМ промтом: роль, рамки, регистр, критерии проверки, пар-блоки, глоссарий."""
|
||
cfg = PAIRS[p]
|
||
cfg["wire"]()
|
||
key = cfg["para"]().key
|
||
units_blk, reflow_blk = pair_blocks(p)
|
||
if not units_blk or not reflow_blk:
|
||
raise SystemExit(f"⛔ у пары {p} нет блока «{UNITS_HEAD}» либо «{REFLOW_HEAD}» — "
|
||
"промт собрать нечем, арм не строится")
|
||
core, _few, user = PR.load_template(CORE)
|
||
core = (core.replace("{{ПАР_ЕДИНИЦЫ}}", units_blk)
|
||
.replace("{{ПАР_ПЕРЕВЁРСТКА}}", reflow_blk))
|
||
brief = getattr(PR, PR.PAIR_BRIEF[key])
|
||
sysmsg = PR.render(core, brief, src, "")
|
||
# ⚠ БАНК-ЗАКОН НА ВСЕХ АРМАХ БЕЗ ИЗЪЯТИЙ (норма D39.104). Прежнее отступление — 72 клетки без
|
||
# блока — владелец пометил как дефект. Термы берутся ТОЛЬКО релевантные этому фрагменту:
|
||
# прямое требование брифа V6 «не давать слишком много контекста».
|
||
bf = None if key == "zh-ru" else cfg["para"]().bank_file
|
||
law = PR.law_block(PR.terms_in(src, bf), bf)
|
||
out = [{"role": "system", "content": sysmsg}]
|
||
if law:
|
||
# ⚠ БАНК-ЗАКОН НАПИСАН ДЛЯ ДВУХСТАДИЙНОГО ПУТИ и говорит «в ЧЕРНОВИКЕ термин слева должен
|
||
# быть передан формой справа». У однопроходки черновика нет вовсе: модель получила бы
|
||
# указание про текст, которого не существует. Поймано селфтестом ДО покупки. Правится
|
||
# ровно одно слово и ровно здесь — трогать общий `law_block` нельзя, он едет в бою.
|
||
out.append({"role": "system", "content": law.replace("в черновике", "в переводе")})
|
||
out.append({"role": "user", "content": PR.render(user, brief, src, "")})
|
||
return out
|
||
|
||
|
||
# ── ГЕЙТ ПОКРЫТИЯ БОЕВЫХ ПРОМТОВ ─────────────────────────────────────────────────────────────
|
||
# ⚠⚠ ЗАВЕДЁН ПО ЗАМЕЧАНИЮ ВЛАДЕЛЬЦА 20.08 («там же довольно много пропущено?»), И ЗАМЕЧАНИЕ БЫЛО
|
||
# ВЕРНЫМ. Первая редакция нового промта молча потеряла три правила боевых промтов, и одно из них
|
||
# потеряла ОПАСНО: «повтор в параллельных позициях исходника — авторский рефрен, сохраняй его» —
|
||
# при том что новый промт прямо говорит «буквальность не достоинство», то есть толкает в обратную
|
||
# сторону. Ещё два: «кальки жестов и идиом · неверно понятые реалии» и «не пересочиняй сцены».
|
||
#
|
||
# Обещание «я всё перенёс» проверяемо только механизмом, поэтому вот он. Каждое ПРАВИЛО боевых
|
||
# промтов перечислено ниже характерной подстрокой, и у каждого объявлена ДИСПОЗИЦИЯ:
|
||
# "промт" — правило стоит в ядре нового промта (проверяется вхождением ключевых слов);
|
||
# "блок" — правило приезжает пар-блоком дословно (проверяется вхождением самой строки);
|
||
# "снято" — правило снято СОЗНАТЕЛЬНО, с основанием прямо здесь.
|
||
# Селфтест роняет замер, если хоть одна строка боевого промта не покрыта ни одной диспозицией:
|
||
# правка боевого промта чужой зоной сломает мой гейт ГРОМКО, а не тихо изменит смысл арма.
|
||
COVERAGE: tuple[tuple[str, str, str, str], ...] = (
|
||
# (файл, характерная подстрока боевого правила, диспозиция, чем закрыто / основание)
|
||
("translator", "Хонорифики:", "промт", "Хонорифики"),
|
||
("translator", "Баланс форенизация", "промт", "форенизация/доместикация"),
|
||
("translator", "Сноски:", "промт", "Сноски"),
|
||
("translator", "ничего не пропускай", "промт", "выбрасывать и досочинять нельзя"),
|
||
("translator", "не добавляй от себя", "промт", "появилось событие, которого там не было"),
|
||
# ⚠ «смысл» — правило покрыто ПО СОДЕРЖАНИЮ другим, более полным местом промта, но не
|
||
# дословно. Диспозиция заведена отдельно от «блок» намеренно: смешивать «перенесено буквой» и
|
||
# «покрыто смыслом» значит потерять различие, на котором и погорела первая редакция.
|
||
("translator", "НЕ копируй построчную разбивку", "смысл",
|
||
"Один повествовательный ход оформляй ОДНИМ русским абзацем"),
|
||
("translator", "избегай канцелярита", "промт", "канцеляр"),
|
||
("translator", "Выведи ТОЛЬКО перевод", "промт", "Выведи ТОЛЬКО перевод"),
|
||
("editor", "искажения смысла", "промт", "Перевран факт"),
|
||
("editor", "неверно понятые реалии", "промт", "реалию сперва пойми"),
|
||
("editor", "кальки жестов и идиом", "промт", "подстрочник врёт"),
|
||
("editor", "а не подстрочником", "промт", "подстрочник врёт"),
|
||
# ⚠ СЕРТИФИЦИРУЮЩЕЕ СЛОВО БЫЛО ВЗЯТО ИЗ ПРАВИЛА ПРОТИВОПОЛОЖНОГО СМЫСЛА. Строка
|
||
# «убирай лишние повторы» покрывалась словом «разнообразь», которое стоит в
|
||
# рефрен-границе — то есть там, где повторы убирать ЗАПРЕЩЕНО. Гейт печатал
|
||
# «покрыто», а требования убирать немотивированный повтор в промте не было вовсе.
|
||
# Поймано ревью Fable-5, воспроизведено исполнением. Сертификат пере-привязан.
|
||
("editor", "лишние повторы", "промт", "немотивированные повторы убирай"),
|
||
("editor", "авторский рефрен", "промт", "авторский рефрен"),
|
||
("editor", "СТРОГО по приведённому глоссарию", "промт", "КАНОН"),
|
||
("editor", "склоняя по контексту", "промт", "склоняя по контексту"),
|
||
("editor", "не придумывай иных вариантов", "промт", "вариантов не придумывай"),
|
||
("editor", "естественные русские абзацы", "смысл",
|
||
"Один повествовательный ход оформляй ОДНИМ русским абзацем"),
|
||
("editor", "Реплики прямой речи", "смысл",
|
||
"Каждую новую реплику начинай с нового абзаца через тире"),
|
||
("editor", "Сохраняй ПОЛНОТУ", "промт", "Пропал смысловой ход"),
|
||
("editor", "не пересочиняй сцены", "промт", "сцены не пересочиняй"),
|
||
# ⚠ Строк-ЗАГОЛОВКОВ обоих блоков в таблице нет намеренно: они не правила и не
|
||
# сканируются, а как строки таблицы были мёртвыми — то есть создавали видимость
|
||
# покрытия. Присутствие самих блоков сторожат отдельные проверки селфтеста.
|
||
# строки, которые приезжают ПАР-БЛОКАМИ дословно (их тело — данные пары, не наше)
|
||
("translator", "МЕРЫ", "блок", "Единицы и приёмы"),
|
||
("translator", "СТИХИ и названия классики", "блок", "Единицы и приёмы"),
|
||
("translator", "Род и форму имён", "блок", "Единицы и приёмы"),
|
||
("translator", "РАМОЧНЫЙ РАССКАЗ", "блок", "Единицы и приёмы"),
|
||
("translator", "ФРАНЦУЗСКИЙ СЛОЙ", "блок", "Единицы и приёмы"),
|
||
("translator", "РЕЧЕВЫЕ ПОРТРЕТЫ", "блок", "Единицы и приёмы"),
|
||
("translator", "КАЛЬКИ en→ru", "блок", "Единицы и приёмы"),
|
||
("translator", "ЗАГЛАВНЫЕ РЕАЛИИ", "блок", "Единицы и приёмы"),
|
||
# у редактора СВОЯ сжатая копия единиц; в промт едет БОЛЕЕ ПОЛНАЯ версия переводчика
|
||
("editor", "Единицы и приёмы", "смысл", "Единицы и приёмы"),
|
||
("editor", "Молча определи смысловые ходы", "блок", "ДИСКУРС-ПЕРЕВЁРСТКА"),
|
||
("editor", "Один повествовательный ход", "блок", "ДИСКУРС-ПЕРЕВЁРСТКА"),
|
||
("editor", "Каждую новую реплику", "блок", "ДИСКУРС-ПЕРЕВЁРСТКА"),
|
||
("editor", "Разрешено объединять и делить", "блок", "ДИСКУРС-ПЕРЕВЁРСТКА"),
|
||
("editor", "Хонорифики:", "промт", "Хонорифики"),
|
||
# ⚠ ЕДИНСТВЕННОЕ СОЗНАТЕЛЬНОЕ СНЯТИЕ, И ОНО ОБЪЯВЛЕНО ЗДЕСЬ, А НЕ УМОЛЧАНО. Боевой промт
|
||
# переводчика требует «не сокращай»; решение владельца 16.08 («вольность не ошибка, живой
|
||
# язык — приоритет») это правило отменяет, и новый промт прямо разрешает «сворачивать
|
||
# описание». Полнота при этом охраняется НЕ запретом сокращать, а запретом терять смысловые
|
||
# ХОДЫ — то есть на уровне содержания, а не числа слов.
|
||
("translator", "не сокращай", "снято", "решение владельца 16.08: вольность не ошибка"),
|
||
# Роль редактора-правщика однопроходке не передаётся по построению: чинить нечего.
|
||
("editor", "Тебе даны ИСХОДНЫЙ текст и его черновой перевод", "снято", "черновика нет"),
|
||
("editor", "Сверяй смысл черновика", "снято", "черновика нет"),
|
||
("editor", "художественная редактура черновика", "снято", "черновика нет"),
|
||
("editor", "Ты — литературный редактор", "снято", "роль задана заново: переводчик"),
|
||
("editor", "Выведи ТОЛЬКО отредактированный", "снято", "своя строка вывода у переводчика"),
|
||
("translator", "Ты — профессиональный литературный переводчик", "снято", "роль задана заново"),
|
||
("translator", "Жанр книги:", "снято", "бриф перенесён своим блоком"),
|
||
("editor", "Жанр книги:", "снято", "бриф перенесён своим блоком"),
|
||
)
|
||
_RULE_MARK = ("- ", "1.", "2.", "3.", "4.", "Ты —", "Жанр", "Твой мандат", "Выведи")
|
||
|
||
|
||
# ⚠ МЁРТВАЯ СТРОКА ТАБЛИЦЫ НЕОТЛИЧИМА ОТ ЖИВОЙ — и это дефект гейта, а не косметика:
|
||
# строка, не срабатывающая ни разу, создаёт видимость покрытия там, где его никто не проверял.
|
||
# Поймано ревью Fable-5. `fired` наполняется при разборе и сверяется селфтестом.
|
||
fired: set = set()
|
||
|
||
|
||
def coverage_report(p: str) -> list[tuple[str, str, str]]:
|
||
"""[(файл, строка боевого промта, вердикт)] — что покрыто, чем и что не покрыто вовсе."""
|
||
cfg = PAIRS[p]
|
||
cfg["wire"]()
|
||
d = PR.PAIR_DIR[cfg["para"]().key]
|
||
u = chosen(p)[0]
|
||
sysall = "\n".join(x["content"] for x in rol_msgs(p, u["source"]) if x["role"] == "system")
|
||
ub, rb = pair_blocks(p)
|
||
out = []
|
||
fired.clear()
|
||
for fname in ("translator", "editor"):
|
||
core, _f, _us = PR.load_template(d / f"{fname}.md")
|
||
for ln in core.splitlines():
|
||
s = ln.strip()
|
||
# ⚠ ПОРОГ ДЛИНЫ БЫЛ СЛЕПЫМ ПЯТНОМ: правило «- Сноски: {{footnotes}}» короче
|
||
# 25 знаков и не сканировалось ВООБЩЕ, то есть могло пропасть незаметно.
|
||
if len(s) < 10 or not s.startswith(_RULE_MARK):
|
||
continue
|
||
hits = [c for c in COVERAGE if c[0] == fname and c[1] in s]
|
||
fired.update(c[:3] for c in hits)
|
||
if not hits:
|
||
out.append((fname, s[:90], "⛔ НЕ ОБЪЯВЛЕНО НИ ОДНОЙ ДИСПОЗИЦИЕЙ"))
|
||
continue
|
||
for _f2, _sub, disp, what in hits:
|
||
if disp == "снято":
|
||
out.append((fname, s[:90], f"снято: {what}"))
|
||
elif disp == "смысл":
|
||
out.append((fname, s[:90], "покрыто смыслом"
|
||
if what in sysall else f"⛔ «{what}» НЕТ в промте"))
|
||
elif disp == "блок":
|
||
# ⚠ Сверяется САМА СТРОКА боевого промта, а не начало блока: слабая проверка
|
||
# («блок непустой») пропустила бы усечённый блок, из которого выпала половина
|
||
# правил. Строка рендерится тем же брифом, иначе `{{venuti}}` не совпадёт.
|
||
line = PR.render(s, getattr(PR, PR.PAIR_BRIEF[cfg["para"]().key]), "", "")
|
||
out.append((fname, s[:90],
|
||
"блок пары ДОСЛОВНО" if line in sysall else "⛔ строка НЕ доехала"))
|
||
else:
|
||
out.append((fname, s[:90],
|
||
"в промте" if what in sysall else f"⛔ «{what}» НЕТ в промте"))
|
||
return out
|
||
|
||
|
||
def src_of(p: str, arm: str, u: dict) -> str:
|
||
"""Исходник, который видит арм. ОДНА точка правды — иначе смета и покупка разъедутся.
|
||
|
||
⚠ Все армы, кроме `RA`, видят ПЛОСКИЙ исходник — тот же самый, что видела база `ZP`. Иначе
|
||
несущий контраст `RN/ZP` смешал бы эффект промта с эффектом подачи.
|
||
"""
|
||
if arm in RESTORE_ARMS and PAIRS[p]["restore"]:
|
||
return PAIRS[p]["restore"](u["source"])
|
||
return u["source"]
|
||
|
||
|
||
def tag(p: str, arm: str, uid: str) -> str:
|
||
return f"dv-{PAIRS[p]['letter']}-{arm.lower()}-{uid}"
|
||
|
||
|
||
def cell(tg: str) -> str:
|
||
f = OUT / f"{tg}.json"
|
||
if not f.exists():
|
||
return ""
|
||
d = json.loads(f.read_text(encoding="utf-8"))
|
||
return (d.get("content") or "") if d.get("finish") == "stop" else ""
|
||
|
||
|
||
def free_text(p: str, arm: str, uid: str) -> str:
|
||
"""Бесплатные армы берутся у рига захода Д17 — те же главы, те же тексты, $0."""
|
||
return ZK.free_text(p, arm, uid)
|
||
|
||
|
||
def chosen(p: str) -> list[dict]:
|
||
"""ТЕ ЖЕ главы, что у захода Д17 — иначе бесплатные армы не переиспользуются и база уедет."""
|
||
return ZK.chosen(p)
|
||
|
||
|
||
def call_kwargs(model: str, msgs: list[dict]) -> dict:
|
||
"""Боевая конфигурация модели. Отступлений нет: у `grok-4.3` вендор-дефолт = размышление ON,
|
||
а запрет D30.1 прямой — reasoning-off в редакторской роли даёт no-op-редактора."""
|
||
return ROSTER.call_kwargs(model, msgs, max_out=MAX_OUT)
|
||
|
||
|
||
# ── ПОКУПКА ──────────────────────────────────────────────────────────────────────────────────
|
||
def buy(p: str, which: list[str]) -> None:
|
||
ZK.price_gate()
|
||
cfg = PAIRS[p]
|
||
cfg["wire"]()
|
||
ZK.preflight([ARMS[a][0] for a in which], cfg["phase"], cfg["letter"])
|
||
us = chosen(p)
|
||
led = MONEY.Guarded(cfg["phase"], default_expect=0.03)
|
||
print(f"\nпара {p} · глав {len(us)} · армы {' '.join(which)} · касса {cfg['phase']}")
|
||
for arm in which:
|
||
model, gen = ARMS[arm]
|
||
if arm in RESTORE_ARMS and not cfg["restore"]:
|
||
print(f" {arm} на паре {p} невозможен: исходник абзацев не терял — пропуск")
|
||
continue
|
||
for u in us:
|
||
tg = tag(p, arm, u["uid"])
|
||
if cell(tg).strip():
|
||
continue
|
||
# ⚠ Гейт перелома цены — перед КАЖДОЙ клеткой, а не раз на старте: прогон длится
|
||
# часами, и пересёкший 16:00 UTC цикл писал бы вызовы по вчерашнему прайсу.
|
||
ZK.price_gate(quiet=True)
|
||
rec = MONEY.purchase(led, tg, model, ED22.client(model),
|
||
call_kwargs(model, rol_msgs(p, src_of(p, arm, u))),
|
||
role="onepass", uid=u["uid"], arm=arm, gen=gen, pair=p,
|
||
podacha="абзацы" if arm in RESTORE_ARMS else "плоско")
|
||
if rec.get("skipped"):
|
||
raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на клетке {tg} — прогон ОСТАНОВЛЕН.\n"
|
||
" Резать панель или поднимать потолок решением сессии ЗАПРЕЩЕНО.\n"
|
||
" Это СТОП и вопрос владельцу: назвать недостающую сумму.")
|
||
if rec.get("finish") != "stop":
|
||
print(f" ⚠ {tg}: finish={rec.get('finish')} "
|
||
f"{str(rec.get('error'))[:120]} — клетка объявляется несобранной")
|
||
elif C.draft_defect(cell(tg)):
|
||
# штатный гейт годности проекта: эхо исходника вместо перевода ловится ЗДЕСЬ,
|
||
# иначе негодная клетка уедет в судейскую пачку за полную цену
|
||
print(f" ⚠ {tg}: {C.draft_defect(cell(tg))} — клетка НЕГОДНА, объявить в отчёте")
|
||
MONEY.report()
|
||
|
||
|
||
# ── $0-ГЕЙТ КАНОНА: там, где однопроходка уже выигрывала ──────────────────────────────────────
|
||
def canon() -> None:
|
||
print("КАНОН-ГЕЙТ ($0, детерминированно): потерянных терминов банка на главу, МЕНЬШЕ лучше\n")
|
||
for p, cfg in PAIRS.items():
|
||
cfg["wire"]()
|
||
us = chosen(p)
|
||
print(f"=== пара {p} · глав {len(us)} ===")
|
||
for arm in FREE + tuple(ARMS):
|
||
vals = []
|
||
for u in us:
|
||
t = free_text(p, arm, u["uid"]) if arm in FREE else cell(tag(p, arm, u["uid"]))
|
||
if t.strip():
|
||
vals.append(len(C.canon_gaps(u["source"], t)))
|
||
if vals:
|
||
print(f" {arm:4s} n={len(vals):2d} потерь/глава {st.mean(vals):5.2f}")
|
||
|
||
|
||
# ── СМЕТА И ПРОВЕРКИ ─────────────────────────────────────────────────────────────────────────
|
||
def price_of(p: str, model: str) -> float:
|
||
"""Ожидаемая цена клетки — из ЗАМЕРЕННЫХ токенов уже купленных ОДНОПРОХОДОК ЭТОЙ пары,
|
||
пере-оценённых ТЕКУЩИМ пином. Зашитых цен в файле нет: после пере-пина они врали вчетверо."""
|
||
toks = []
|
||
for f in OUT.glob(f"{C.tag(PAIRS[p]['onepass'], '')}*.json"):
|
||
if any(x in f.name for x in ("ERROR", "LENGTH", "ЭХО", "STALE", "TWIN")):
|
||
continue
|
||
r = json.loads(f.read_text(encoding="utf-8"))
|
||
if r.get("finish") == "stop" and r.get("prompt_tokens"):
|
||
toks.append((r["prompt_tokens"], r.get("cached_tokens") or 0,
|
||
r.get("completion_tokens") or 0, r.get("reasoning_tokens") or 0,
|
||
r.get("total_tokens") or 0))
|
||
if not toks:
|
||
return 0.03
|
||
med = [st.median([x[i] for x in toks]) for i in range(5)]
|
||
# ⚠ Новый промт ДЛИННЕЕ склейки на входе — берём фактическую длину системного промта, а не
|
||
# длину прежнего: смета, посчитанная по старому входу, занижала бы резерв гарда.
|
||
grow = _sys_len(p) / max(_sys_len_old(p), 1)
|
||
return ROSTER.cost(model, int(med[0] * grow), int(med[1]), int(med[2]), int(med[3]),
|
||
int(med[4]))
|
||
|
||
|
||
def _sys_len(p: str) -> int:
|
||
u = chosen(p)[0]
|
||
return sum(len(m["content"]) for m in rol_msgs(p, u["source"]))
|
||
|
||
|
||
def _sys_len_old(p: str) -> int:
|
||
PAIRS[p]["wire"]()
|
||
u = chosen(p)[0]
|
||
return sum(len(m["content"]) for m in C.a2_msgs(u["source"]))
|
||
|
||
|
||
def cmd_show(p: str) -> None:
|
||
"""Промт целиком, глазами. Обязательная вычитка ПЕРЕД покупкой."""
|
||
u = chosen(p)[0]
|
||
for m in rol_msgs(p, u["source"]):
|
||
body = m["content"]
|
||
if m["role"] == "user":
|
||
body = body.split("Переведи следующий фрагмент:")[0] + \
|
||
"Переведи следующий фрагмент:\n\n<текст главы, %d знаков>" % len(u["source"])
|
||
print(f"\n────────── {m['role'].upper()} ──────────\n{body}")
|
||
|
||
|
||
def cmd_dry() -> None:
|
||
print("ОДНОПРОХОДКА КАК РОЛЬ · главы те же, что у захода Д17\n")
|
||
ZK.price_gate()
|
||
tot = 0.0
|
||
for p, cfg in PAIRS.items():
|
||
cfg["wire"]()
|
||
us = chosen(p)
|
||
print(f"\n=== пара {p} · глав {len(us)} · касса {cfg['phase']} ===")
|
||
print(f" системный промт: новый {_sys_len(p)} знаков против склейки {_sys_len_old(p)} "
|
||
f"({_sys_len(p) / max(_sys_len_old(p), 1):.2f}×)")
|
||
sub = 0.0
|
||
for arm, (model, _g) in ARMS.items():
|
||
if arm in RESTORE_ARMS and not cfg["restore"]:
|
||
print(f" {arm:4s} {'—':18s} на этой паре невозможен (исходник абзацев не терял)")
|
||
continue
|
||
need = sum(1 for u in us if not cell(tag(p, arm, u["uid"])).strip())
|
||
pr = price_of(p, model)
|
||
sub += need * pr
|
||
print(f" {arm:4s} {model:18s} надо {need:2d} × ${pr:.5f} = ${need * pr:.4f}")
|
||
sp = MONEY.Guarded(cfg["phase"]).spent()
|
||
ceil = MONEY.CEILINGS.get(cfg["phase"], 0.0)
|
||
mark = "ВЛЕЗАЕТ" if sub <= ceil - sp else f"НЕ ВЛЕЗАЕТ (не хватает ${sub - (ceil - sp):.4f})"
|
||
print(f" ИТОГО ${sub:.4f} · потолок ${ceil:.2f} · потрачено ${sp:.4f} → {mark}")
|
||
tot += sub
|
||
print(f"\nВЕСЬ ЗАМЕР: ${tot:.4f} по текущему (пиковому) пину. В офф-пик DeepSeek вдвое дешевле.")
|
||
|
||
|
||
def cmd_selftest() -> int:
|
||
bad = 0
|
||
|
||
def ck(n: str, ok: bool, d: str = "") -> None:
|
||
nonlocal bad
|
||
bad += not ok
|
||
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
|
||
|
||
for p, cfg in PAIRS.items():
|
||
cfg["wire"]()
|
||
us = chosen(p)
|
||
ck(f"{p}: глав ровно 16", len(us) == 16, f"их {len(us)}")
|
||
ck(f"{p}: все три бесплатных арма есть на каждой главе",
|
||
all(free_text(p, a, u["uid"]).strip() for a in FREE for u in us))
|
||
u = us[0]
|
||
m = rol_msgs(p, u["source"])
|
||
sysall = "\n".join(x["content"] for x in m if x["role"] == "system")
|
||
# ⚠ ПАР-БЛОКИ ДОЕХАЛИ. Пустой блок сделал бы промт беднее боевого, и замер сравнивал бы
|
||
# не «роль против склейки», а «роль минус пар-специфика против склейки».
|
||
ub, rb = pair_blocks(p)
|
||
ck(f"{p}: блок «{UNITS_HEAD}» пары подтянут", len(ub) > 200, f"{len(ub)} знаков")
|
||
ck(f"{p}: блок «{REFLOW_HEAD}» пары подтянут", len(rb) > 400, f"{len(rb)} знаков")
|
||
ck(f"{p}: оба блока стоят В ПРОМТЕ", ub[:60] in sysall and rb[:60] in sysall)
|
||
ck(f"{p}: плейсхолдеры раскрыты все до одного", "{{" not in sysall)
|
||
# ⚠ ГЛАВНОЕ ОТЛИЧИЕ ОТ СКЛЕЙКИ — мета-фразы про конвейер быть не должно
|
||
ck(f"{p}: мета-фразы про отсутствующего редактора НЕТ",
|
||
"НЕ БУДЕТ" not in sysall and "после тебя" not in sysall)
|
||
ck(f"{p}: вольность РАЗРЕШЕНА явно", "ТЫ ВПРАВЕ" in sysall)
|
||
ck(f"{p}: регистр назван отдельной рамкой", "РЕГИСТР" in sysall)
|
||
ck(f"{p}: критерии проверки напечатаны в промте",
|
||
"ПО ЧЕМУ БУДЕТ ПРОВЕРЕН" in sysall and "ошибкой НЕ СЧИТАЕТСЯ" in sysall)
|
||
ck(f"{p}: черновик в промт НЕ попадает (однопроходка)",
|
||
"ЧЕРНОВ" not in sysall.upper() and "черновик" not in sysall.lower())
|
||
ck(f"{p}: банк-закон на месте либо термов в главе нет",
|
||
bool(PR.law_block(PR.terms_in(u["source"], None if cfg["para"]().key == "zh-ru"
|
||
else cfg["para"]().bank_file),
|
||
None if cfg["para"]().key == "zh-ru"
|
||
else cfg["para"]().bank_file)) == (len(m) == 3),
|
||
f"сообщений {len(m)}")
|
||
ck(f"{p}: пользовательское сообщение несёт ТЕКСТ ГЛАВЫ",
|
||
u["source"][:80] in m[-1]["content"])
|
||
ck(f"{p}: ровно одно пользовательское сообщение",
|
||
sum(1 for x in m if x["role"] == "user") == 1)
|
||
# промт обязан ОТЛИЧАТЬСЯ от склейки, иначе замер пуст
|
||
ck(f"{p}: новый промт не равен склейке",
|
||
sysall.strip() != "\n".join(x["content"] for x in C.a2_msgs(u["source"])
|
||
if x["role"] == "system").strip())
|
||
# теги и касса
|
||
tgs = {tag(p, a, u["uid"]) for a in ARMS}
|
||
ck(f"{p}: теги армов различны", len(tgs) == len(ARMS))
|
||
ck(f"{p}: теги покрыты глобом кассы {cfg['phase']}",
|
||
all(t.startswith(f"dv-{cfg['letter']}-") for t in tgs))
|
||
ck(f"{p}: фаза заведена в кассе", cfg["phase"] in MONEY.CEILINGS)
|
||
ck(f"{p}: две генерации несущей модели — РАЗНЫЕ теги",
|
||
tag(p, "RN", u["uid"]) != tag(p, "RN2", u["uid"]))
|
||
# ⚠ ГЕЙТ ПОКРЫТИЯ — главная проверка этого файла. Обещание «я перенёс всё из боевых
|
||
# промтов» проверяемо только механизмом; замечание владельца 20.08 показало, что на слово
|
||
# его брать нельзя.
|
||
rep = coverage_report(p)
|
||
miss = [r for r in rep if r[2].startswith("⛔")]
|
||
ck(f"{p}: КАЖДОЕ правило боевых промтов имеет диспозицию и она исполнена",
|
||
not miss, f"не покрыто {len(miss)} из {len(rep)}: " + " | ".join(r[1][:50] for r in miss[:3]))
|
||
# ⚠ И ОБРАТНАЯ СТОРОНА: строка таблицы, не сработавшая НИ РАЗУ, создаёт видимость покрытия
|
||
# там, где никто ничего не проверял. Считается по ОБЕИМ парам сразу: часть правил живёт
|
||
# только у английской (французский слой, рамочный рассказ).
|
||
seen: set = set()
|
||
for p in PAIRS:
|
||
coverage_report(p)
|
||
seen |= fired
|
||
dead = [c for c in COVERAGE if c[:3] not in seen]
|
||
ck("в таблице COVERAGE нет МЁРТВЫХ строк (каждая срабатывает хотя бы раз)",
|
||
not dead, " | ".join(f"{c[0]}:«{c[1]}»" for c in dead[:4]))
|
||
ck("пары не делят буквы тегов", len({v["letter"] for v in PAIRS.values()}) == len(PAIRS))
|
||
ck("несущий контраст объявлен один", len(PRIMARY) == 2 and PRIMARY == ("RN", "ZP"))
|
||
for a, (mo, _g) in ARMS.items():
|
||
try:
|
||
ck(f"цена считается для {a} ({mo})",
|
||
MONEY.M22.BUY.cost(mo, 3000, 0, 3600, 800, 7400) > 0)
|
||
except Exception as e: # noqa: BLE001
|
||
ck(f"цена считается для {a} ({mo})", False, f"{type(e).__name__}: {e}")
|
||
ck("grok идёт с размышлением (D30.1: reasoning-off = no-op-редактор)",
|
||
"reasoning_effort" not in json.dumps(call_kwargs("grok-4.3",
|
||
[{"role": "user", "content": "x"}])))
|
||
print(f"\n{'ЗАМЕР ГОДЕН К ПОКУПКЕ' if not bad else f'ПРОВАЛОВ: {bad}'}")
|
||
return bad
|
||
|
||
|
||
if __name__ == "__main__":
|
||
a = sys.argv[1:] or ["--dry"]
|
||
if a[0] == "--selftest":
|
||
sys.exit(1 if cmd_selftest() else 0)
|
||
elif a[0] == "--dry":
|
||
cmd_dry()
|
||
elif a[0] == "--show":
|
||
cmd_show(next((x for x in a[1:] if x in PAIRS), "zh"))
|
||
elif a[0] == "--canon":
|
||
canon()
|
||
elif a[0] == "--coverage":
|
||
for p in ([x for x in a[1:] if x in PAIRS] or list(PAIRS)):
|
||
print(f"\n=== ПОКРЫТИЕ БОЕВЫХ ПРОМТОВ, пара {p} ===")
|
||
for fname, line, verdict in coverage_report(p):
|
||
print(f" [{verdict:32s}] {fname}: {line}")
|
||
elif a[0] == "--buy":
|
||
pr = [x for x in a[1:] if x in PAIRS] or list(PAIRS)
|
||
ar = [x for x in a[1:] if x in ARMS] or list(ARMS)
|
||
for x in pr:
|
||
buy(x, ar)
|
||
else:
|
||
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --show --dry --selftest --buy --canon")
|