#!/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") PARA = _load("para", ZONE / "para.py") B = PARA.B # тот же экземпляр рига, что видит провайдер пары # ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ. # Модуль `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") # ⚠ ДИСПЕТЧЕР ПО ВИДУ КОНТУРА, а не по имени арма. Пре-регистрация английской оси называет армы # E0…E6, а механизмы у них те же, что у китайских A-армов. Копировать цикл покупки ради других # букв значит копировать и будущие расхождения; поэтому имя арма отображается в ВИД, и цикл один. ARM_KIND = {"A1": "flags", "A1B": "flags", "A6F": "flags", "A4": "canonfix", "A2": "onepass", "A3": "critic", "A6": "critic", # английская ось Д3 (имена — из пре-регистрации, механизмы — те же) "E1": "flags", "E2": "onepass", "E3": "critic", "E4": "canonfix", # японская ось Д6 — разведка: связка и однопроходка "J2": "onepass"} def phase_letter(arm: str) -> str: """Буква фазы в теге. Одна на все теги оси — включая половины шумового пола.""" P = pair() if P.key == "zh-ru": return "f" if arm.startswith("A6") else "a" # ⚠ ВТОРОЙ РЕДАКТОР ПАРЫ живёт в СВОЕЙ кассе, а не в кассе оси: он куплен под гипотезу H-4 # («порядок жильцов — свойство пары»), а не под замер самой оси, и смешивать их потолки # значит потерять границу того, что санкционировано подо что. E6 — ФД-G (en), J6 — ФД-H (ja). return {"E6": "g", "J6": "h", "JFLO": "h"}.get(arm) or {"en-ru": "b", "ja-ru": "c"}[P.key] def phase_of(arm: str) -> str: """Фаза кассы арма. Должна совпадать с буквой в `tag`, иначе трата уедет мимо потолка.""" P = pair() if P.key == "zh-ru": return "ФД-F" if arm.startswith("A6") else "ФД-A" if arm in ("E6", "J6", "JFLO"): return {"E6": "ФД-G", "J6": "ФД-H", "JFLO": "ФД-H"}[arm] return {"en-ru": "ФД-B", "ja-ru": "ФД-C"}[P.key] # ⚠ ПОЧЕМУ 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) и в судейскую пачку НЕ идут", } # ── ПАРА КАК ПАРАМЕТР ───────────────────────────────────────────────────────────────────────── # ⚠ Заведено 14.08 по вычитке. Хуки в риге были построены, но НЕ ПОДКЛЮЧЕНЫ ни в одной точке # входа: греп по `eval/` не находил ни одного вызова `battery.configure_pair`/`bank.configure`. # Забытая проводка даёт не падение, а китайский разбор величин и палладий-линт на английском # тексте — целевой класс «тихо неверное число». Здесь единственное место, где пара включается, # и включается ЦЕЛИКОМ: риг, банк, бриф, письменность, теги и источник единиц разом. _PAIR = None _UNITS = None # ⚠ БЮДЖЕТ ВЫВОДА — ПАРАМЕТР ОСИ. Умолчание рига 16000 хватает китайским единицам, но не японским: # у DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, а японский текст плотнее на # знак и даёт более длинный русский перевод. Замер 14.08: 3 клетки из 27 вернулись ПУСТЫМИ с # `finish=length`, деньги списаны, содержимого нет. Клетка при этом СУЩЕСТВУЕТ файлом, поэтому # счёт «9/9» выглядел полным — считались файлы, а не годное содержимое. _MAX_OUT = 16000 def pair(): global _PAIR if _PAIR is None: _PAIR = PARA.ZH return _PAIR def configure(p, units_provider=None, base_draft_hook=None, base_a0_hook=None, max_out: int | None = None) -> None: """Перевести контур на пару `p`. Для zh — проверенный no-op (селфтест это стережёт).""" global _PAIR, _UNITS, BASE_DRAFT_HOOK, BASE_A0_HOOK, _MAX_OUT if max_out: _MAX_OUT = max_out BASE_DRAFT_HOOK, BASE_A0_HOOK = base_draft_hook, base_a0_hook _PAIR, _UNITS = p, units_provider B.configure_pair(SOURCE_VALUES_HOOK=p.values_in_source, NAME_CONFORMANT_TEXT_HOOK=p.name_conformant_text, NAME_CONFORMANT_BANK_HOOK=p.name_conformant, SOURCE_SCRIPT_HOOK=p.source_script) BANK.configure(p, material=lambda: "\n".join(u["source"] for u in units())) def _bank() -> dict: P = pair() return BANK.PR.bank(None if P.key == "zh-ru" else P.bank_file) # ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ──────────────────────── def units() -> list[dict]: """ВСЕ единицы среза, включая исключённые, — для учёта и кассы.""" if _UNITS is not None: return _UNITS() 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 return BO.draft_reject_reason(text or "") # Источники баз: у zh — сырьё прошлых паков, у новой пары их даёт драйвер оси. Умолчания # сохраняют китайское поведение побайтно (стережёт селфтест «проводка zh — no-op»). BASE_DRAFT_HOOK = None BASE_A0_HOOK = None 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 поверх черновика, который штатный гейт проекта бракует. Здесь ветки уравнены; брак не возвращается молча, а называется вслух. """ if BASE_DRAFT_HOOK is not None: return BASE_DRAFT_HOOK(uid) 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. ⛔⛔ БЛОКЕР Б8 РЕВЬЮ 21.08. Прежняя редакция читала `content` БЕЗ проверки `finish` — в отличие от соседнего `base_draft`, где гейт годности стоит на ОБЕИХ ветках. Через эту щель клетка `tp2-edit-E-deepseek-v4-flash-41599f30df` с `finish=length` (5759 знаков против медианы панели 6888, оборвана кульминация) уехала армом `A0` в ШЕСТЬ судейских ключей (`d4p1`, `d4p2`, `d1p1`, `d1p2`, `sol-d1p1`, `sol-d1p2`), в заход z17 и в китайскую панель Д26. В каждой пачке она заражает не один слот, а три: сам `A0` и производные от него декой с маржой. ⚠ Сертифицирующий норму селфтест был к этому СЛЕП по построению: он фильтрует по наличию файла `dv-a-a0-{uid}.json`, а таких файлов на диске ноль — арм просто выпадал из проверки, и гейт зеленел вхолостую на арме, который является базой ВСЕХ контрастов Д4 и Д1. ⛔⛔ ПОЧИНКА ЗДЕСЬ ГАСИТ ТЕКСТ И ЭТИМ ЛОМАЕТ ОТБОР — ПРОВЕРЕНО ИСПОЛНЕНИЕМ, НЕ ПОВТОРЯТЬ. Первая редакция фикса возвращала `""` на негодной клетке, как это делает `base_draft`. Но `base_draft` стоит на арме, чья пустота законно выбрасывает единицу, а `A0` входит в `pool()`, и `chosen()` берёт N_UNITS от ХЕША: выброшенную единицу молча заменила другая, и «те же 16 глав» перестали быть теми же. Гейт годности обязан стоять на СБОРКЕ ПАНЕЛИ (`rol.emit_read`, `rol.verify_read`), а не на источнике текста: там он роняет пакет, не трогая отбор. Здесь остаётся ГРОМКОЕ имя дефекта — молчания быть не должно, подмены отбора тоже. """ if BASE_A0_HOOK is not None: return BASE_A0_HOOK(uid) t = P22.edit_text(ANCHOR, uid) if t.strip() and not P22.edit_ok(ANCHOR, uid): print(f" ⚠ {uid}: клетка A0 ОБОРВАНА вендором (finish != stop) — в панель не годится") return t if t.strip() else PAN23.text_b("R0", uid) def a0_ok(uid: str) -> bool: """Годна ли клетка `A0` этой единицы. Спрашивать ПЕРЕД сборкой судейской панели (блокер Б8).""" if BASE_A0_HOOK is not None: return bool(BASE_A0_HOOK(uid).strip()) return not P22.edit_text(ANCHOR, uid).strip() or P22.edit_ok(ANCHOR, 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 gaps = [] for t, v in _bank().items(): n = src.count(t) if not n: continue if min(len(re.findall(v["rx"], out, re.IGNORECASE)), 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 bank = _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(pair().source_script.findall(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_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в # системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому # не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ. bf = None if pair().key == "zh-ru" else pair().bank_file law = PR.law_block(PR.terms_in(src, bf), bf) or "" 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_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в # системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому # не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ. bf = None if pair().key == "zh-ru" else pair().bank_file law = PR.law_block(PR.terms_in(src, bf), bf) or "" 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. ⛔⛔ БЛОКЕР Б1 РЕВЬЮ 21.08, ПОЧИНЕН ЗДЕСЬ. Прежняя редакция грузила `PR.BATTLE / "editor.md"`, а `BATTLE` — жёстко китайский путь (`tenant_panel/prompts.py:29`). На английской и японской парах в промт уезжал КИТАЙСКИЙ мандат: «时辰 = 2 часа», «доля 成 — десятые», «передачи китайского паратаксиса», чэнъюй «物是人非» — правила про текст, которого у этого арма нет. В одном системном сообщении стояли ДВА противоречащих правила о мерах: переводческая половина пары требовала «МЕРЫ английской традиции… НЕ пересчитывай в метры», приклеенная китайская — «МЕРЫ приводи к привычным читателю единицам». Соседний арм `Z8` делал это правильно с самого начала (`zakhod.py:274`, через `PAIR_DIR`) — то есть верный образец лежал в репозитории рядом. ⚠ На китайской паре поведение НЕ МЕНЯЕТСЯ побайтно: `PAIR_DIR["zh-ru"] is BATTLE`. ⚠ Починка кода НЕ чинит уже купленные клетки `dv-b-e2-*` (en) и `dv-c-j2-*` (ja): они сделаны заражённым промтом, и всякий вывод об «однопроходке» на этих парах читается только вместе с эрратой отчёта. """ core, _few, _user = PR.load_template(PR.PAIR_DIR[pair().key] / "editor.md") core = PR.render(core, getattr(PR, pair().brief_key), "", "") 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]: """Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте.""" bf = None if pair().key == "zh-ru" else pair().bank_file m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src, bf), bf), pair=pair().key) m[0]["content"] = m[0]["content"] + "\n\n" + a2_mandate() return m def tag(arm: str, uid: str, part: str = "") -> str: # ⚠ Тег несёт пару: без неё кэш `purchase` отдал бы оплаченную КИТАЙСКУЮ клетку под видом # английской, за $0 и без предупреждения. У zh префикс пустой — теги уже купленных клеток # обязаны остаться прежними. # Буква в теге — ФАЗА КАССЫ, а не пара: глоб `dv-b-*` покрывает ФД-B, `dv-a-*` — ФД-A. # Пар-префикс, стоявший здесь до 14.08, давал `dv-en-…`, который не покрыт ни одним глобом, # и покупка отверглась бы гейтом кассы — лучше так, чем мимо потолка, но работать нельзя. ph = phase_letter(arm) 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)}") # ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог. _b0 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"])))) for u in units_ok() if base_draft(u["uid"]).strip()] _t0 = tag("A1B", "zzz") configure(PARA.ZH) _b1 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"])))) for u in units_ok() if base_draft(u["uid"]).strip()] ck("проводка пары zh — no-op: числа и теги закрытой оси не двигаются", _b0 == _b1 and _t0 == tag("A1B", "zzz"), f"было {sum(x for x, _ in _b0)}/{sum(y for _, y in _b0)}, " f"стало {sum(x for x, _ in _b1)}/{sum(y for _, y in _b1)}") 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, max_out=_MAX_OUT), **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(phase_of(arm)) kind = ARM_KIND[arm] 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 kind == "onepass": # однопроходка уравненного мандата _buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm) continue if kind in ("flags", "canonfix"): # детерминированные контуры target = base_a0(uid) if kind == "canonfix" else draft places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен" for g in canon_gaps(src, target)] if kind == "canonfix" 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 quarantine_length(tags: list[str]) -> int: """Увести клетки `finish=length` с пустым содержимым в карантин `.LENGTH.json`. ⚠ Не удалять: деньги за них списаны и обязаны остаться видимыми кассе (глоб считает `dv-*.json`, и карантинное имя под него по-прежнему попадает). Удаление спрятало бы трату. """ n = 0 for tg in tags: f = OUT / f"{tg}.json" if not f.exists(): continue rec = json.loads(f.read_text(encoding="utf-8")) if rec.get("finish") == "length" and not (rec.get("content") or "").strip(): f.rename(OUT / f"{tg}.LENGTH.json") print(f" {tg}: пустая клетка (finish=length) → карантин") n += 1 return n def buy_bases(phase: str, us: list[dict], pfx: str, draft_arm: str, edit_arm: str, draft_model: str, edit_model: str, pair_key: str, law_fn, expect: float) -> None: """Черновик и боевая связка новой пары — общий механизм для всех неканонных осей. ⚠ Вынесено, чтобы японская ось не была КОПИЕЙ английской. Копия цикла покупки означала бы копию всех будущих расхождений: гард, кэш, гейт годности черновика и порядок «сначала база, потом контуры» должны быть одни и те же на всех осях, иначе разница осей начнёт мерить разницу их драйверов. """ led = MONEY.Guarded(phase, default_expect=expect) def cell(tg: str) -> str: f = OUT / f"{tg}.json" return (json.loads(f.read_text(encoding="utf-8")).get("content") or "") if f.exists() else "" for u in us: # сначала ВСЕ черновики tg = f"{pfx}-{draft_arm.lower()}-{u['uid']}" if cell(tg).strip(): continue rec = MONEY.purchase(led, tg, draft_model, ED22.client(draft_model), ROSTER.call_kwargs(draft_model, PR.translator_msgs(u["source"], None, pair=pair_key), max_out=_MAX_OUT), role="translator", uid=u["uid"], arm=draft_arm) if rec.get("skipped"): raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу") for u in us: # затем связка поверх них draft = base_draft(u["uid"]) if not draft.strip(): print(f" {u['uid']}: черновика нет — {edit_arm} пропущен") continue tg = f"{pfx}-{edit_arm.lower()}-{u['uid']}" if cell(tg).strip(): continue rec = MONEY.purchase(led, tg, edit_model, ED22.client(edit_model), ROSTER.call_kwargs(edit_model, PR.editor_msgs(u["source"], draft, law_fn(u["source"]), pair=pair_key), max_out=_MAX_OUT), role="editor", uid=u["uid"], arm=edit_arm) if rec.get("skipped"): raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу") 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, и контроль оказался пуст. Половины судят РАЗНЫЕ сессии (это забота судейского рига, здесь — только генерация). """ # ⚠ Пол принадлежит ОСИ, а не китайскому паку: касса, буква тега и состав единиц берутся # у текущей пары. Прежняя редакция звала `ФД-A` и `units_zh()` безусловно, то есть на # английской оси купила бы китайский пол в китайскую кассу — и порог оси считался бы по # чужому шуму. Критик-арм передаётся, потому что у осей он называется по-разному. # ⚠ Источник мест зависит от того, ЕСТЬ ли у оси смысловой критик. У японской разведки его # нет (панель — связка и однопроходка), и `critic_places` там возвращает пусто на КАЖДОЙ # единице: пол молча не покупается, а прогон завершается успехом. Проверено исполнением # 14.08 — японский пол вышел 0 из 18, и это выглядело как «купили», а не как «нечего купить». P = pair() crit_arm = {"zh-ru": "A3", "en-ru": "E3"}.get(P.key) ref_arm = crit_arm or {"ja-ru": "J2"}[P.key] led = MONEY.Guarded(phase_of(ref_arm)) src_units = ([u for u in P22.M.units_zh() if u["uid"] not in EXCLUDED] if pair().key == "zh-ru" else units_ok()) for u in src_units: uid, src = u["uid"], u["source"] draft = base_draft(uid) if not draft.strip(): continue places = (critic_places(crit_arm, uid)[0] if crit_arm else flags_of(u, draft)) if not places: print(f" {uid}: мест нет — единица в пол не идёт") continue for half in ("flo1", "flo2"): tg = f"dv-{phase_letter(ref_arm)}-{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")