600 lines
44 KiB
Python
600 lines
44 KiB
Python
#!/usr/bin/env python3
|
||
"""Д4 — EDIT-КОНТУР НА zh. Покупает армы A1–A4 и A6 на 32 единицах. Платный.
|
||
|
||
Вопрос фазы (ставка владельца H-2б): нужен ли ПОЛНЫЙ ПЕРЕПИС, или дешевле и лучше найти
|
||
проблемные места и починить точечно. Армы объявлены в пре-реге Д0.5 и здесь не меняются:
|
||
|
||
A0 боевой full-regen (deepseek-v4-pro поверх якорного черновика) КУПЛЕН обоими паками, $0
|
||
A1 черновик + ДЕТЕРМИНИРОВАННЫЕ флаги (батарея + канон-гейт) → точечный фиксер
|
||
A2 dspro-однопроходка УРАВНЕННОГО мандата (строка 153 бэклога) — редактора нет вовсе
|
||
A3 черновик + СМЫСЛОВОЙ LLM-критик («прочитай, пойми, найди всё проблемное») → фиксер
|
||
A4 A0 + канон-фиксер ПОСЛЕ (полировка: чинит потери канона боевого редактора)
|
||
A6 ДРУГАЯ БАЗА: черновик dspro эксп-22 + оба контура — прямой носитель H-1
|
||
|
||
⚠ ПОЧЕМУ ФИКСЕР ВИДИТ ТОЛЬКО МЕСТА, А НЕ ВЕСЬ ТЕКСТ. Если дать фиксеру полный мандат, он станет
|
||
редактором, и контраст «точечно против переписа» исчезнет. Поэтому вход фиксера — черновик плюс
|
||
СПИСОК МЕСТ, и в промте прямо запрещено трогать что-либо ещё. Это и есть измеряемая позиция
|
||
контура; нарушение её — не оптимизация, а подмена арма.
|
||
|
||
⚠ ЧТО ДЕЛАЕТ КАНОН-ГЕЙТ. Детерминированная сверка покрытия банка после КАЖДОГО арма: он и
|
||
флагует потери для фиксера (вход A1/A4), и печатается отдельной осью результата. Политика
|
||
владельца 10.08: потеря канона = дефект безусловно; художественность — отдельная ось.
|
||
|
||
Запуск: contour.py --plan | --selftest | --run [A1B A2 A3 A4 A6F] | --floor
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
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
|
||
|
||
|
||
MONEY = _load("money_d", ZONE / "money_d.py")
|
||
PAN23 = _load("panel23", REPO / "eval" / "editor_tier" / "panel.py")
|
||
P22, MAT = PAN23.P22, PAN23.MAT
|
||
PR = _load("prompts22", REPO / "eval" / "tenant_panel" / "prompts.py")
|
||
BANK = _load("bank22", REPO / "eval" / "tenant_panel" / "bank.py")
|
||
ROSTER = _load("roster22", REPO / "eval" / "tenant_panel" / "roster.py")
|
||
ED22 = _load("editors22", REPO / "eval" / "tenant_panel" / "editors.py")
|
||
|
||
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ.
|
||
# Модуль `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним. `money_d`
|
||
# ставил `BUY.OUT` на каталог фазы, а грузившийся следом `panel.py` пака 23 тянул его кассу и
|
||
# перебивал обратно на `~/books/editor-tier`. Итог был бесшумным и дорогим: кэш-чек, замок и
|
||
# `finally` смотрели в каталог фазы, а РЕЗУЛЬТАТ писался в каталог чужого пака. Семь оплаченных
|
||
# клеток ушли туда, касса фазы показывала $0, а цикл покупки 20 минут ждал появления файла,
|
||
# который в её каталоге не появился бы никогда. Найдено многоагентным разбирательством 10.08.
|
||
MONEY.M22.configure(ZONE=ZONE.parent / "dovodka", OUT=MONEY.OUT, TAGS=MONEY.TAGS,
|
||
CEILINGS=MONEY.CEILINGS, PACK_CEILING=MONEY.PACK_CEILING,
|
||
GLOB_ALL="dv-*.json", ROSTER=ROSTER)
|
||
if MONEY.M22.BUY.OUT != MONEY.OUT: # ассерт, а не надежда
|
||
raise SystemExit(f"⛔ buy.OUT={MONEY.M22.BUY.OUT} против кассы {MONEY.OUT} — "
|
||
"покупки уедут в чужой каталог, СТОП")
|
||
|
||
OUT = MONEY.OUT
|
||
ANCHOR = "deepseek-v4-flash" # якорный черновик
|
||
FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок
|
||
DRAFT2 = "deepseek-v4-pro" # «качественный черновик» H-1 (арм D2 эксп-22)
|
||
ARMS = ("A1", "A1B", "A2", "A3", "A4", "A6", "A6F")
|
||
|
||
# ⚠ ПОЧЕМУ A1 И A1B — ДВА АРМА, А НЕ ПЕРЕ-ПОКУПКА ОДНОГО. Куплённые 30 клеток `A1` получили список
|
||
# мест ТОЛЬКО от канон-гейта: батарея падала на 32/32 и ошибку глотал голый except (эррата Э-4).
|
||
# Переименовать их значило бы разойтись с полем `tag` внутри самих записей, а править оплаченное
|
||
# сырьё нельзя. Поэтому арм, объявленный заказом («батарея + канон-гейт»), покупается под НОВЫМ
|
||
# тегом `A1B` и он же несёт H-2а в первичном семействе; `A1` остаётся как есть и объявляется
|
||
# ОПИСАТЕЛЬНЫМ армом «только канон-флаги». Побочная выгода честная: разница A1B/A1 меряет, что
|
||
# добавляет батарея к канон-гейту, и это ответ на ту же гипотезу с другой стороны.
|
||
|
||
# ⚠ ПОПРАВКА ФАЗЫ Д (11.08, после ревью 82 находок). Пре-рег Д0.5 называет A6 армом «dspro-черновик
|
||
# + ОБА контура». Один арм двух контуров нести не может: контур — это способ получить выход, и
|
||
# выходов у двух контуров два. Куплённый `A6` есть СМЫСЛОВОЙ контур; флаговый заводится отдельным
|
||
# армом `A6F` на той же базе. Эррата объявлена ДО покупки A6F, старые клетки не трогаются.
|
||
PLACES_CAP = 200 # было 40 — резало 34% находок критика (см. эрратy Д0.11)
|
||
|
||
|
||
# ⚠ ЕДИНИЦЫ, ИСКЛЮЧЁННЫЕ ИЗ ОСИ, — ПЕРЕЧИСЛЕНЫ ЗДЕСЬ С ПРИЧИНОЙ И ЦЕНОЙ.
|
||
# Исключение — не сокращение выборки решением сессии, а применение ШТАТНОГО гейта проекта, который
|
||
# на этой ветке не применялся вовсе (см. `base_draft`). Объявляется до судейства, а не после.
|
||
EXCLUDED = {
|
||
"63ab55ac5c": "якорный черновик — эхо исходника (гейт bakeoff.draft_reject_reason); "
|
||
"клетки A1/A3 на ней уже оплачены ($0.0231) и в судейскую пачку НЕ идут",
|
||
}
|
||
|
||
|
||
# ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ────────────────────────
|
||
def units() -> list[dict]:
|
||
"""ВСЕ единицы среза, включая исключённые, — для учёта и кассы."""
|
||
return P22.M.units_zh() + MAT.units_new()
|
||
|
||
|
||
def units_ok() -> list[dict]:
|
||
"""Единицы, ДОПУСТИМЫЕ к покупке и судейству: без брака базы."""
|
||
return [u for u in units() if u["uid"] not in EXCLUDED]
|
||
|
||
|
||
def draft_defect(text: str) -> str:
|
||
"""Штатный гейт годности черновика проекта. Пусто = годен."""
|
||
import bakeoff as BO # noqa: PLC0415
|
||
return BO.draft_reject_reason(text or "")
|
||
|
||
|
||
def base_draft(uid: str) -> str:
|
||
"""Якорный черновик единицы. У старых — из сырья эксп-22, у новых — из фазы B эксп-23.
|
||
|
||
⚠ ГЕЙТ ГОДНОСТИ НА ОБЕИХ ВЕТКАХ. Старая ветка `P22.draft_text` фильтрует брак сама
|
||
(`tenant_panel/panel.py:130` — `if c and not BO.draft_reject_reason(c)`), а `PAN23.draft_b`
|
||
для 16 НОВЫХ единиц не фильтрует ничего. Через эту щель в фазу Д прошла эхо-мина
|
||
`63ab55ac5c`: на ней куплены клетки армов A1 и A3 поверх черновика, который штатный гейт
|
||
проекта бракует. Здесь ветки уравнены; брак не возвращается молча, а называется вслух.
|
||
"""
|
||
t = P22.draft_text(ANCHOR, uid)
|
||
if t.strip():
|
||
return t
|
||
t = PAN23.draft_b(uid)
|
||
why = draft_defect(t)
|
||
if why:
|
||
print(f" ⚠ {uid}: черновик БРАКОВАН гейтом проекта ({why}) — единица непригодна")
|
||
return ""
|
||
return t
|
||
|
||
|
||
def base_a0(uid: str) -> str:
|
||
"""Боевой full-regen над якорной базой — арм A0, куплен, $0."""
|
||
t = P22.edit_text(ANCHOR, uid)
|
||
return t if t.strip() else PAN23.text_b("R0", uid)
|
||
|
||
|
||
def base_draft2(uid: str) -> str:
|
||
"""«Качественный черновик» H-1: черновик dspro эксп-22. Есть только у 16 старых единиц."""
|
||
return P22.draft_text(DRAFT2, uid)
|
||
|
||
|
||
# ── флаги: детерминированные места, которые фиксер обязан починить ────────────────────────────
|
||
def canon_gaps(src: str, out: str) -> list[str]:
|
||
"""Термины банка, чья канонная форма В ВЫХОДЕ встречается реже, чем в исходнике."""
|
||
import re # noqa: PLC0415
|
||
gaps = []
|
||
for t, v in BANK.PR.bank().items():
|
||
n = src.count(t)
|
||
if not n:
|
||
continue
|
||
if min(len(re.findall(v["rx"], out, re.I)), n) < n:
|
||
gaps.append(f"«{v['dst']}» (в исходнике {t} встречается {n} раз)")
|
||
return gaps
|
||
|
||
|
||
# ⚠ ПОЛЯ БАТАРЕИ, КОТОРЫЕ ОЗНАЧАЮТ НАРУШЕНИЕ, — ПЕРЕЧИСЛЕНЫ ЯВНО, И ЭТО НЕ ПЕДАНТИЗМ.
|
||
# Первая редакция брала из `run_unit` всё, что `isinstance(v, (int, float)) and v`. Дефект был
|
||
# двойной и оба слоя молчали: (1) на вход шёл `dict`, а не `battery.Unit`, и `run_unit` падал
|
||
# `AttributeError: 'dict' object has no attribute 'final'` на 32/32 единицах — ошибку глотал голый
|
||
# `except`, так что ВСЕ 30 оплаченных клеток A1 куплены на одном канон-гейте, хотя арм объявлен
|
||
# «батарея + канон-гейт»; (2) значения проверок — СЛОВАРИ, и наивный фильтр, будь он даже
|
||
# достижим, вымел бы в список мест ЗНАМЕНАТЕЛИ: «проверка sentences дала 90», «words дала 1154».
|
||
# То есть починка одного типа сделала бы арм хуже, чем сломанный. Поэтому whitelist, а не эвристика.
|
||
# ⚠⚠ ВТОРАЯ ПОЧИНКА (11.08, после того как 14 клеток A1B из 31 вернули ПУСТОТУ на потолке вывода,
|
||
# $0.170 впустую). Первая редакция whitelist'а брала поля-СЧЁТЧИКИ, и два из них были ядовиты:
|
||
#
|
||
# * `palladius.nonconformant_bank` — это счёт строк САМОГО БАНКА, не прошедших палладиевскую
|
||
# проверку (`battery.py:159` — `for d in bank_dst`). К переводу он не относится ВООБЩЕ: число
|
||
# одинаково во всех 32 единицах, потому что банк один. Фиксеру говорилось «имена/термины
|
||
# переданы мимо банка (8 шт.)», и он искал в тексте то, чего в тексте нет. Рассуждение
|
||
# выросло вшестеро против A1 (40 694 знака против 7 400) и съело весь бюджет вывода.
|
||
# * `ty_vy.mixed_in_unit` — адреса не имеет и сам докстринг проверки (`battery.py:365-370`)
|
||
# называет её «сигналом к просмотру, а не счётом ошибок»: смешение между РАЗНЫМИ
|
||
# собеседниками легитимно. Инструкцией фиксеру такое быть не может.
|
||
#
|
||
# НОРМА, ЗАВЕДЁННАЯ ЭТИМ: **флаг без адреса — не место.** Контракт арма — «черновик плюс СПИСОК
|
||
# МЕСТ»; поле, которое не может назвать, ЧТО чинить, в список не идёт и остаётся описательным.
|
||
# Поэтому ниже у каждого поля указано, откуда берётся адрес, и поля без адреса исключены.
|
||
BATTERY_VIOLATIONS = {
|
||
# поле-признак поле-адрес (None = адрес находится по самому признаку)
|
||
"palladius": (("nonconformant_text", "nonconformant_text_words"),),
|
||
"cjk_leak": (("over_prod_threshold", None),),
|
||
"typography": (("ascii_quotes", None), ("hyphen_as_dash", None)),
|
||
"numbers": (("lost_n", "lost"), ("invented_n", "invented")),
|
||
"gender": (("mismatched", "cases"),),
|
||
}
|
||
BATTERY_SAYS = {
|
||
"palladius.nonconformant_text": "не-палладиевская транскрипция имени: {a}",
|
||
"cjk_leak.over_prod_threshold": "в переводе остались иероглифы исходника: {a}",
|
||
"typography.ascii_quotes": "прямые кавычки вместо «ёлочек», {v} шт.",
|
||
"typography.hyphen_as_dash": "дефис вместо тире, {v} шт.",
|
||
"numbers.lost_n": "величины исходника отсутствуют в переводе: {a}",
|
||
"numbers.invented_n": "в переводе есть величины, которых нет в исходнике: {a}",
|
||
"gender.mismatched": "род персонажа расходится с карточкой: {a}",
|
||
}
|
||
# ОПИСАТЕЛЬНЫЕ (печатаются как ось результата, фиксеру НЕ передаются — адреса нет):
|
||
# ty_vy.mixed_in_unit · palladius.nonconformant_bank · typography.violations (агрегат)
|
||
|
||
|
||
def battery_flags(u: dict, draft: str) -> list[str]:
|
||
"""Нарушения батареи НА ТЕКСТЕ, каждое с адресом. Падение = дефект, НЕ глотаем."""
|
||
import battery as B # noqa: PLC0415
|
||
import re # noqa: PLC0415
|
||
bank = BANK.PR.bank()
|
||
unit = B.Unit(source=u["source"], draft=draft, final=draft,
|
||
bank_dst={v["dst"] for v in bank.values()},
|
||
term_pairs=[(t, v["dst"]) for t, v in bank.items() if u["source"].count(t)])
|
||
r = B.run_unit(unit)
|
||
out = []
|
||
for check, fields in BATTERY_VIOLATIONS.items():
|
||
val = r.get(check)
|
||
if not isinstance(val, dict):
|
||
continue
|
||
for sig, addr_field in fields:
|
||
v = val.get(sig)
|
||
if not (isinstance(v, (int, float, bool)) and v):
|
||
continue
|
||
if addr_field:
|
||
addr = val.get(addr_field) or []
|
||
elif check == "cjk_leak":
|
||
addr = sorted(set(re.findall(r"[一-鿿]", draft)))[:12]
|
||
else:
|
||
addr = []
|
||
if addr_field and not addr: # признак есть, адреса нет — не место
|
||
continue
|
||
says = BATTERY_SAYS[f"{check}.{sig}"]
|
||
out.append("БАТАРЕЯ: " + says.format(
|
||
v=int(v), a="; ".join(f"«{x}»" for x in addr[:12])))
|
||
return out
|
||
|
||
|
||
def flags_of(u: dict, draft: str) -> list[str]:
|
||
"""Список мест для A1. Только ДЕТЕРМИНИРОВАННОЕ: канон-гейт + батарея, без судьи и без LLM."""
|
||
out = [f"КАНОН: термин {g} передан не канонной формой либо пропущен"
|
||
for g in canon_gaps(u["source"], draft)]
|
||
return out + battery_flags(u, draft)
|
||
|
||
|
||
# ── промты армов: данные, не код ──────────────────────────────────────────────────────────────
|
||
FIX_HEAD = (
|
||
"Ты — редактор-корректор. Тебе дан ИСХОДНИК, его ЧЕРНОВОЙ ПЕРЕВОД и СПИСОК МЕСТ, где перевод "
|
||
"неверен.\n\n"
|
||
"Почини ТОЛЬКО перечисленные места. Всё остальное оставь ПОБАЙТНО как есть: не переписывай "
|
||
"фразы, не улучшай стиль, не меняй вёрстку, не трогай пунктуацию там, где о ней не сказано. "
|
||
"Если место починить нельзя, не трогая соседнего текста, — почини минимально возможной "
|
||
"правкой.\n\nВыведи ТОЛЬКО полный текст перевода после правок, без преамбул и комментариев.")
|
||
CRIT_HEAD = (
|
||
"Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК и его ЧЕРНОВОЙ ПЕРЕВОД.\n\n"
|
||
"ПРОЧИТАЙ оба текста и найди ВСЁ проблемное в переводе: искажения и потери смысла, неверно "
|
||
"понятые реалии, кальки, канцелярит, несуществующие слова, рассогласование, сбитую вёрстку "
|
||
"реплик, непоследовательную передачу имён и терминов.\n\n"
|
||
"Выведи СПИСОК МЕСТ, по одному в строке, в формате:\n"
|
||
"- <цитата из перевода> → <в чём ошибка>\n\n"
|
||
"Только список. Не переписывай текст, не предлагай готовый перевод целиком.")
|
||
|
||
|
||
def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]:
|
||
law = PR.law_block(PR.terms_in(src))
|
||
body = (f"{FIX_HEAD}\n\n{law}\n\n---USER---\nИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}\n\n"
|
||
"МЕСТА, КОТОРЫЕ НАДО ПОЧИНИТЬ:\n" + "\n".join(f"- {p}" for p in places))
|
||
s, u = body.split("---USER---", 1)
|
||
return [{"role": "system", "content": s.strip()}, {"role": "user", "content": u.strip()}]
|
||
|
||
|
||
def crit_msgs(src: str, draft: str) -> list[dict]:
|
||
# Критику банк-закон нужен не меньше фиксера: без него он назовёт «непоследовательным» то,
|
||
# что канону как раз отвечает, и наоборот. Банк-закон D39.104 — на ВСЕХ армах, без изъятий.
|
||
law = PR.law_block(PR.terms_in(src))
|
||
return [{"role": "system", "content": f"{CRIT_HEAD}\n\n{law}"},
|
||
{"role": "user", "content": f"ИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"}]
|
||
|
||
|
||
# ── A2: однопроходка УРАВНЕННОГО мандата (строка 153 бэклога, долг с эксп-21) ─────────────────
|
||
# ⚠ ЗАЧЕМ УРАВНИВАТЬ. Контраст «однопроходка против связки» в эксп-21 был загрязнён: у арма без
|
||
# редактора системный промт вдвое короче (×1.92), и замеренная разница могла быть разницей ОБЪЁМА
|
||
# ИНСТРУКЦИИ, а не топологии. Строка 153 бэклога держит этот долг с тех пор и здесь закрывается:
|
||
# A2 получает СУММУ мандатов боевой связки — промт переводчика ПЛЮС мандатные части промта
|
||
# редактора — в ОДИН вызов. Тогда `A2/A0` меряет топологию, а не количество указаний.
|
||
#
|
||
# ⚠ ПРЕЖНЯЯ РЕДАКЦИЯ ЭТОГО МЕСТА НЕ РАБОТАЛА ВОВСЕ: `PR.translator_msgs(src)` звался с одним
|
||
# аргументом при сигнатуре `(src, block, en=False)` → TypeError, а сторож `hasattr` проверяет
|
||
# существование имени, а не арность, поэтому ветка «арм пропущен, объявить в отчёте» была
|
||
# НЕДОСТИЖИМА. Арм не куплен ни разу; вдобавок `block=None` снял бы банк-закон D39.104, который
|
||
# заказ требует на ВСЕХ армах. Оба дефекта закрыты здесь.
|
||
#
|
||
# Преобразование промта редактора — ДАННЫЕ, не код: таблицы ниже читаются глазами и сторожатся
|
||
# гейтом `--selftest`. Строки, предполагающие наличие черновика, переписываются, а не выбрасываются:
|
||
# выбросить их значило бы снять мандат, ради уравнивания которого арм и заводится.
|
||
A2_DROP = (
|
||
"Ты — литературный редактор", # роль уже задана промтом переводчика
|
||
"Жанр книги:", # дубль строки переводчика
|
||
"Хонорифики:", # дубль строки переводчика
|
||
"Выведи ТОЛЬКО отредактированный", # у переводчика своя строка вывода
|
||
)
|
||
A2_SUBST = (
|
||
("Твой мандат — художественная редактура черновика со сверкой по исходнику:",
|
||
"Отдельного редактора после тебя НЕ БУДЕТ, поэтому мандат редактуры входит в твой:"),
|
||
("Сверяй смысл черновика с исходным текстом", "Сверяй смысл перевода с исходным текстом"),
|
||
("Сохраняй ПОЛНОТУ черновика", "Сохраняй ПОЛНОТУ исходника"),
|
||
("редактуры черновика", "редактуры перевода"),
|
||
)
|
||
|
||
|
||
def a2_mandate() -> str:
|
||
"""Мандатная часть боевого редактора, переписанная под однопроходку. $0, детерминирована."""
|
||
core, _few, _user = PR.load_template(PR.BATTLE / "editor.md")
|
||
core = PR.render(core, PR.BRIEF_ZH, "", "")
|
||
out = []
|
||
for ln in core.splitlines():
|
||
if any(ln.strip().startswith(d) for d in A2_DROP):
|
||
continue
|
||
for a, b in A2_SUBST:
|
||
ln = ln.replace(a, b)
|
||
out.append(ln)
|
||
return "\n".join(out).strip()
|
||
|
||
|
||
def a2_msgs(src: str) -> list[dict]:
|
||
"""Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте."""
|
||
m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src)))
|
||
m[0]["content"] = m[0]["content"] + "\n\n" + a2_mandate()
|
||
return m
|
||
|
||
|
||
def tag(arm: str, uid: str, part: str = "") -> str:
|
||
ph = "f" if arm.startswith("A6") else "a"
|
||
return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
|
||
|
||
|
||
def text_of(arm: str, uid: str) -> str:
|
||
f = OUT / f"{tag(arm, uid)}.json"
|
||
if f.exists():
|
||
return json.loads(f.read_text(encoding="utf-8")).get("content") or ""
|
||
return ""
|
||
|
||
|
||
def cmd_plan() -> None:
|
||
us = units()
|
||
old = {u["uid"] for u in P22.M.units_zh()}
|
||
print(f"единиц {len(us)} (старых {len(old)} + новых {len(us) - len(old)})")
|
||
have = {a: sum(1 for u in us if text_of(a, u["uid"]).strip()) for a in ARMS}
|
||
# ⚠ Печатать «куплено N из M» без второй колонки — значит сертифицировать полноту арма его
|
||
# же счётчиком: клетка, записанная БЕСПЛАТНО (мест не нашлось), в первой колонке неотличима
|
||
# от оплаченной, а именно на этой неразличимости прошлая редакция объявила A1 «30/30 платных».
|
||
for a in ARMS:
|
||
n = len(old) if a.startswith("A6") else len(us)
|
||
free = sum(1 for u in us
|
||
if (OUT / f"{tag(a, u['uid'])}.json").exists()
|
||
and json.loads((OUT / f"{tag(a, u['uid'])}.json").read_text()).get("free"))
|
||
print(f" {a}: клеток {have[a]:2d} из {n}" + (f" (из них БЕСПЛАТНЫХ {free})" if free else ""))
|
||
flo = sum(1 for u in P22.M.units_zh() for h in ("flo1", "flo2")
|
||
if (OUT / f"dv-a-{h}-{u['uid']}.json").exists())
|
||
print(f" свой шумовой пол: клеток {flo} из {2 * len(old)} (ПАРАМИ, обе половины — "
|
||
"отдельные генерации)")
|
||
base = sum(1 for u in us if base_draft(u["uid"]).strip())
|
||
a0 = sum(1 for u in us if base_a0(u["uid"]).strip())
|
||
d2 = sum(1 for u in us if base_draft2(u["uid"]).strip())
|
||
print(f"базы (уже куплены, $0): черновик {base}/{len(us)} · A0 {a0}/{len(us)} · "
|
||
f"черновик-dspro {d2}/{len(old)}")
|
||
MONEY.report()
|
||
|
||
|
||
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 ""))
|
||
|
||
us = units_ok()
|
||
ck("единиц ровно 32 минус исключённые", len(us) == 32 - len(EXCLUDED),
|
||
f"{len(us)} из 32; исключено {len(EXCLUDED)}")
|
||
for uid, why in EXCLUDED.items():
|
||
print(f" ⚠ ИСКЛЮЧЕНА {uid}: {why}")
|
||
ck("uid уникальны", len({u["uid"] for u in us}) == len(us))
|
||
miss = [u["uid"] for u in us if not base_draft(u["uid"]).strip()]
|
||
ck("якорный черновик есть у всех ДОПУСТИМЫХ", not miss, f"нет у {miss[:3]}")
|
||
miss0 = [u["uid"] for u in us if not base_a0(u["uid"]).strip()]
|
||
ck("арм A0 (боевой перепис) есть у всех", not miss0, f"нет у {miss0[:3]}")
|
||
old = P22.M.units_zh()
|
||
miss2 = [u["uid"] for u in old if not base_draft2(u["uid"]).strip()]
|
||
ck("черновик-dspro для H-1 есть у всех 16 старых", not miss2, f"нет у {miss2[:3]}")
|
||
# ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест.
|
||
# ⚠ Прежняя редакция сверяла `fix_msgs(...)` САМА С СОБОЙ на одном и том же черновике —
|
||
# тавтология, которая не могла провалиться и потому ничего не сторожила. Теперь входы армов
|
||
# берутся ТЕМ ЖЕ кодом, что и в бою (`cmd_run`), и сверяются между собой.
|
||
u = us[0]
|
||
d = base_draft(u["uid"])
|
||
m1 = fix_msgs(u["source"], d, flags_of(u, d) or ["КАНОН: тест"])
|
||
m3 = fix_msgs(u["source"], d, critic_places("A3", u["uid"])[0] or ["СМЫСЛ: тест"])
|
||
ck("A1 и A3 делят вход, различаясь только списком мест",
|
||
m1[0]["content"] == m3[0]["content"] and m1[1]["content"] != m3[1]["content"])
|
||
ck("вход A4 — это A0, а не черновик (позиция «ПОСЛЕ редактора»)",
|
||
base_a0(u["uid"]).strip() != d.strip())
|
||
ck("в промте фиксера стоит запрет трогать остальное",
|
||
"ПОБАЙТНО как есть" in m1[0]["content"])
|
||
# ⚠ Банк-закон D39.104 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО
|
||
# по заголовку блока, а не по слову «закон»: эксп-22 отступил от него на 72 en-клетках.
|
||
ck("банк-закон D39.104 в промте фиксера есть",
|
||
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " "))
|
||
ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку",
|
||
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"])
|
||
# ⚠ БАТАРЕЯ. Прежняя проверка `isinstance(fl, list)` не могла быть ложной: `flags_of` заводит
|
||
# `out = []` и имеет единственный выход `return out`. Она и пропустила смерть батареи на 32/32.
|
||
bf = battery_flags(u, d)
|
||
ck("батарея ЖИВА (падение = дефект, а не пустой список)", isinstance(bf, list))
|
||
tot_b = sum(len(battery_flags(x, base_draft(x["uid"]))) for x in us if base_draft(x["uid"]))
|
||
ck("батарея вносит места в флаговый контур, а не молчит", tot_b > 0, f"нарушений {tot_b}")
|
||
ck("в места батареи не попали ЗНАМЕНАТЕЛИ (sentences/words/lines)",
|
||
not any(w in f for f in bf for w in ("sentences", "words", "lines", "candidates")),
|
||
"; ".join(bf[:2]))
|
||
# ⚠ ФЛАГ БЕЗ АДРЕСА — НЕ МЕСТО. Норма заведена после того, как безадресный флаг (и притом
|
||
# относящийся к БАНКУ, а не к тексту) отправил фиксера в неограниченный перебор: 14 клеток
|
||
# A1B из 31 упёрлись в потолок вывода и вернули пустоту, $0.170 впустую. Гейт требует, чтобы
|
||
# каждое место батареи несло либо цитату в «ёлочках», либо счёт находимого по признаку класса.
|
||
allb = [f for x in us if base_draft(x["uid"]).strip()
|
||
for f in battery_flags(x, base_draft(x["uid"]))]
|
||
noaddr = [f for f in allb if "«" not in f and " шт." not in f]
|
||
ck("каждое место батареи несёт АДРЕС (цитату либо находимый класс)",
|
||
not noaddr, f"без адреса {len(noaddr)}: {noaddr[:2]}")
|
||
ck("батарея не флагует свойства БАНКА вместо свойств текста",
|
||
not any("мимо банка" in f for f in allb))
|
||
# ⚠ A2: арм, которого не существовало — вызов падал TypeError, ветка отказа была недостижима.
|
||
m2 = a2_msgs(u["source"])
|
||
ck("A2 собирается (однопроходка уравненного мандата)", len(m2) >= 2)
|
||
ck("банк-закон D39.104 есть и в A2", any("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in x["content"] for x in m2))
|
||
man = a2_mandate()
|
||
ck("мандат A2 несёт дискурс-перевёрстку (иначе мандат НЕ уравнен)",
|
||
"ДИСКУРС-ПЕРЕВЁРСТКА" in man)
|
||
ck("мандат A2 несёт правило чэнъюй (смысловые атомы)", "чэнъюй" in man)
|
||
ck("в мандате A2 не осталось слова «черновик» — редактировать нечего, он переводит",
|
||
"черновик" not in man.lower(), man[:120].replace("\n", " "))
|
||
ck("в промте A2 не осталось нераскрытых плейсхолдеров",
|
||
not any("{{" in x["content"] for x in m2))
|
||
ck("A2 длиннее голой однопроходки — мандат реально добавлен",
|
||
len(m2[0]["content"]) > len(PR.translator_msgs(u["source"], None)[0]["content"]))
|
||
# теги армов не пересекаются между собой и с чужими паками
|
||
tags = {tag(a, u["uid"]) for a in ARMS}
|
||
ck("теги армов различны", len(tags) == len(ARMS), f"{len(tags)} против {len(ARMS)}")
|
||
ck("теги покрыты глобом кассы", all(t.startswith(("dv-a-", "dv-f-")) for t in tags))
|
||
ck("тег половины пола покрыт глобом кассы", f"dv-a-flo1-{u['uid']}".startswith("dv-a-"))
|
||
ck("обрезка мест не режет ответ критика ни на одной единице",
|
||
PLACES_CAP >= max((critic_places("A3", x["uid"])[1] for x in us), default=0),
|
||
f"кап {PLACES_CAP}, максимум найденного "
|
||
f"{max((critic_places('A3', x['uid'])[1] for x in us), default=0)}")
|
||
# ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог.
|
||
ck("buy.OUT совпадает с кассой фазы", MONEY.M22.BUY.OUT == MONEY.OUT,
|
||
f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}")
|
||
print(f"\n{'ХАРНЕСС ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")
|
||
return bad
|
||
|
||
|
||
def _buy(led, tg: str, msgs: list[dict], **extra) -> dict:
|
||
"""Покупка, у которой отказ гарда есть СТОП, а не пропуск.
|
||
|
||
⚠ Прежняя редакция выбрасывала возврат `MONEY.purchase`. Когда проекционный гард отказывал,
|
||
`buy.purchase` возвращал `skipped=True` и НЕ писал файла, а цикл спокойно шёл дальше — по
|
||
остальным единицам и по следующим армам с их собственными потолками. Так арм A4 оказался
|
||
куплен на 22 единицах из 32, и прогон завершился кодом 0, будто всё в порядке. Объявленная
|
||
норма фазы (`money_d.py` шапка, пре-рег Д0.8) звучит иначе: **срабатывание гарда = СТОП и
|
||
вопрос владельцу**. Здесь она наконец механизирована.
|
||
"""
|
||
rec = MONEY.purchase(led, tg, FIXER, ED22.client(FIXER),
|
||
ROSTER.call_kwargs(FIXER, msgs), **extra)
|
||
if rec.get("skipped"):
|
||
raise SystemExit(
|
||
f"⛔ ГАРД ОТКАЗАЛ на клетке {tg} — прогон ОСТАНОВЛЕН.\n"
|
||
f" Резать ось или поднимать потолок решением сессии ЗАПРЕЩЕНО заказом.\n"
|
||
f" Это СТОП и вопрос владельцу: назвать недостающую сумму и ждать санкции.")
|
||
return rec
|
||
|
||
|
||
def _free_cell(tg: str, text: str, **extra) -> None:
|
||
"""Клетка арма, полученная БЕЗ вызова модели, — записывается с нулевой ценой.
|
||
|
||
⚠ Зачем вообще. Если детерминированный контур не нашёл ни одного места, ответ арма на этой
|
||
единице — исходный текст БЕЗ ПРАВОК, и это законный выход арма, а не отсутствие данных.
|
||
Прежняя редакция такую единицу молча выбрасывала (`continue`), из-за чего A1 приходил на
|
||
судейство с n=30 против n=32 у A3: контрасты переставали быть парными, а из выборки выпадали
|
||
ровно те единицы, где контур не даёт ничего, — то есть смещение шло в пользу арма.
|
||
"""
|
||
p = OUT / f"{tg}.json"
|
||
if p.exists():
|
||
return
|
||
rec = dict(tag=tg, model="(без вызова)", finish="stop", content=text, cost_usd=0.0,
|
||
latency_s=0.0, free=True, **extra)
|
||
tmp = OUT / f"{tg}.json.tmp"
|
||
tmp.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
tmp.replace(p)
|
||
print(f" {tg}: мест нет — выход арма = вход, клетка записана БЕСПЛАТНО (n сохранён)")
|
||
|
||
|
||
def critic_places(arm: str, uid: str) -> tuple[list[str], int]:
|
||
"""Места из ответа критика: (что уйдёт фиксеру, сколько нашлось всего)."""
|
||
cf = OUT / f"{tag(arm, uid, 'crit')}.json"
|
||
if not cf.exists():
|
||
return [], 0
|
||
found = json.loads(cf.read_text(encoding="utf-8")).get("content") or ""
|
||
pl = [ln.strip("- ").strip() for ln in found.splitlines() if ln.strip().startswith("-")]
|
||
return pl[:PLACES_CAP], len(pl)
|
||
|
||
|
||
def cmd_run(which: list[str]) -> None:
|
||
us = units_ok()
|
||
old = {u["uid"] for u in P22.M.units_zh()}
|
||
for arm in which:
|
||
led = MONEY.Guarded("ФД-F" if arm.startswith("A6") else "ФД-A")
|
||
for u in us:
|
||
uid, src = u["uid"], u["source"]
|
||
if arm.startswith("A6") and uid not in old:
|
||
continue
|
||
if text_of(arm, uid).strip():
|
||
continue
|
||
draft = base_draft2(uid) if arm.startswith("A6") else base_draft(uid)
|
||
if not draft.strip():
|
||
print(f" {uid}: базы нет — пропуск")
|
||
continue
|
||
if arm == "A2": # однопроходка уравненного мандата
|
||
_buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm)
|
||
continue
|
||
if arm in ("A1", "A1B", "A4", "A6F"): # детерминированные контуры
|
||
target = base_a0(uid) if arm == "A4" else draft
|
||
places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен"
|
||
for g in canon_gaps(src, target)] if arm == "A4"
|
||
else flags_of(u, target))
|
||
if not places:
|
||
_free_cell(tag(arm, uid), target, role="fixer", uid=uid, arm=arm, places=0)
|
||
continue
|
||
_buy(led, tag(arm, uid), fix_msgs(src, target, places),
|
||
role="fixer", uid=uid, arm=arm, places=len(places))
|
||
continue
|
||
# A3 и A6: смысловой критик, затем фиксер по его списку
|
||
ct = tag(arm, uid, "crit")
|
||
if not (OUT / f"{ct}.json").exists():
|
||
_buy(led, ct, crit_msgs(src, draft), role="critic", uid=uid, arm=arm)
|
||
if not (OUT / f"{ct}.json").exists(): # оплачено, но файла нет
|
||
print(f" {uid}/{arm}: критик не отдал файл — арм пропущен, объявить в отчёте")
|
||
continue
|
||
places, found_n = critic_places(arm, uid)
|
||
if not places:
|
||
_free_cell(tag(arm, uid), draft, role="fixer", uid=uid, arm=arm,
|
||
places=0, places_found=0)
|
||
continue
|
||
_buy(led, tag(arm, uid), fix_msgs(src, draft, places),
|
||
role="fixer", uid=uid, arm=arm, places=len(places), places_found=found_n)
|
||
MONEY.report()
|
||
|
||
|
||
def cmd_floor() -> None:
|
||
"""СВОЙ шумовой пол оси Д4 — ПАРОЙ независимых генераций одного и того же лечения.
|
||
|
||
⚠ Режим был объявлен в шапке файла и НЕ СУЩЕСТВОВАЛ: разбор аргументов знал только
|
||
`--selftest` и `--run`, всё прочее молча уходило в `--plan` с кодом 0. При этом ВСЯ несущесть
|
||
оси Д4 держится на ЗАИМСТВОВАННОМ прайоре sd 2.12 с запасом около 1%: при sd 2.13 ось уже
|
||
описательная. Пол — не украшение, а то, что этот прайор проверяет.
|
||
|
||
Устройство пола отвечает норме Д0.6, купленной дорого: обе половины — ОТДЕЛЬНЫЕ генерации
|
||
одного лечения (фиксер по одному и тому же списку мест над одной и той же базой), поэтому
|
||
судейская разница между ними есть ЧИСТЫЙ шум. Боевая клетка половиной пола не берётся — в
|
||
эксп-23 `CTRLfloorA` совпал с армом `T1` побайтно в 16/16, и контроль оказался пуст.
|
||
Половины судят РАЗНЫЕ сессии (это забота судейского рига, здесь — только генерация).
|
||
"""
|
||
led = MONEY.Guarded("ФД-A")
|
||
old = [u for u in P22.M.units_zh() if u["uid"] not in EXCLUDED]
|
||
for u in old:
|
||
uid, src = u["uid"], u["source"]
|
||
draft = base_draft(uid)
|
||
if not draft.strip():
|
||
continue
|
||
places, _ = critic_places("A3", uid)
|
||
if not places:
|
||
print(f" {uid}: мест нет — единица в пол не идёт")
|
||
continue
|
||
for half in ("flo1", "flo2"):
|
||
tg = f"dv-a-{half}-{uid}"
|
||
if (OUT / f"{tg}.json").exists():
|
||
continue
|
||
_buy(led, tg, fix_msgs(src, draft, places),
|
||
role="floor", uid=uid, arm=half, places=len(places))
|
||
MONEY.report()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
a = sys.argv[1:] or ["--plan"]
|
||
if a[0] == "--selftest":
|
||
sys.exit(1 if cmd_selftest() else 0)
|
||
elif a[0] == "--run":
|
||
cmd_run([x for x in a[1:] if x in ARMS] or list(ARMS))
|
||
elif a[0] == "--floor":
|
||
cmd_floor()
|
||
elif a[0] == "--plan":
|
||
cmd_plan()
|
||
else:
|
||
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --plan --selftest --run --floor")
|