textmachine/docs/architecture/03-implementation-notes.md

28 KiB
Raw Blame History

Заметки реализации (сессия «Бэкенд», 2026-07-04)

Вердикт шага 0 — перевалидации решений v2 глазами реализатора перед Фазой 0. Метод: собственное чтение донорского кода vojo + воркфлоу из 4 критиков (линзы: консистентность доков, исполнимость в Go, сверка с донором построчно, онлайн-фактчек цен/API; ~940k токенов, находки ниже отобраны и объединены). Формат: подтверждаю / правки реализации (фиксируются здесь, архитектурные доки не трогаю) / [НУЖНО РЕШЕНИЕ] (продублировано в PROGRESS).

1. Общий вердикт

Архитектура v2 исполнима, кодить можно. Противоречий, блокирующих Фазу 0, нет. Подтверждаю:

  • Р1 (Go + порт vojo): донор сверен построчно — llm.go/httpllm.go/failover.go/pricing.go/telemetry.go/store.go существуют, объёмы и механизмы соответствуют research/10 (retry-классификация 429/5xx vs терминальный 4xx, типизированная httpStatusError, reserve/settle с TOCTOU-защитой, анти-flapping брейкер, «2xx с пустым контентом = оплаченный вызов», priceFor никогда $0, 137 тест-функций).
  • Р3/Р6 (SQLite, modernc.org/sqlite): FTS5 и WAL в pure-Go драйвере есть; чанк-чекпоинты и kill -9-тест реализуемы (одна маленькая транзакция на LLM-вызов при вызовах в 10300 с — тривиальная нагрузка).
  • Р5 (cache-раскладка): схема «стабильный префикс → волатильный хвост» совместима одновременно с Anthropic cache_control (явные брейкпоинты, max 4) и DeepSeek-автокэшем (prefix-match с 0-го токена, блоки по 64 токена) — селективный глоссарий в хвосте кэш DeepSeek не ломает.
  • Конфликты, разрешённые ревью v2 (кэш↔селективная инъекция, modernc↔sqlite-vec, интерим 18+, чанк-чекпоинты), действительно согласованы по всей цепочке доков.

2. Упущения передавшей сессии, найденные валидацией (главное)

  1. В Usage донора нет cache-writeу Anthropic запись в кэш платная (×1.25 за 5m TTL / ×2 за 1h), поле usage.cache_creation_input_tokens. Без него — систематический недоучёт расходов, тот же класс ошибки, что уже оплаченный в vojo недоучёт reasoning-токенов 3044%. research/10 это предлагал (§3 п.1), но в обязательства Фазы 0 в плане не попало. → В Usage добавляю CacheCreationTokens, в ModelPriceCacheWritePerM; формула: (promptcached)·in + cached·cacheRead + cacheCreation·cacheWrite + (completion+reasoning)·out.
  2. Скрытый третий таймаут донора: полигон знал про 45 с (нога failover) — но в транспорте httpllm.go:214 захардкожен ещё и per-attempt 60 с, а maxTry=3. С чанками 63278 с любая генерация >60 с была бы убита и трижды бесплатно ретраена. → Все таймауты/ретраи — параметры клиента из конфига (профиль per-провайдер per-роль), + чтение Retry-After на 429 (донор его игнорирует, backoff капится 8 с — молоток по rate-limit'ам массового прогона).
  3. Прокси-ловушка глубже, чем в памяти проекта: Go stdlib НЕ проксирует loopback никогда, так что 127.0.0.1 сам по себе безопасен; опасны не-loopback локальные адреса (WSL-gateway 172.x, как в самом vojo). Все клиенты донора — &http.Client{} с ProxyFromEnvironment. → В адаптере локального провайдера и его пробах явный Transport{Proxy: nil}.
  4. Grok 4.1 Fast мёртв (retired 15.05.2026, слаги молча редиректятся на grok-4.3 c ДРУГОЙ ценой $1.25/$2.50) — таблица Р4 в этой части устарела ещё до старта кода. Состав/цены — только configs/models.yaml (Р4 это и предписывает), но канал B остаётся без «дешёвого Grok» — see [НУЖНО РЕШЕНИЕ].
  5. store.go — переписывание, а не «адаптация <20%»: advisory-локов в SQLite нет, весь файл на pgx-API, диалект Postgres (IDENTITY, JSONB, GREATEST, now()). Переносится семантика (reserve/settle/release, миграции per-step, идемпотентные шаги, тесты как поведенческий контракт), не код. Оценку research/10 по этому файлу считать оптимистичной; трудоёмкость Фазы 0 это не ломает (семантика простая).
  6. Приёмка Фазы 0 требует больше, чем список работ Фазы 0: tmctl translate на один чанк draft→edit подразумевает мини-раннер, промпты Translator/Editor и сборку контекста. → Мини-раннер C1 — явный деливерабл Фазы 0 (здесь зафиксирован).

3. Контракты Фазы 0 (обязательства реализации)

3.1 Детерминизм и request-hash (Р6)

Рендер промпта — чистая функция от (snapshot джобы, закоммиченные чекпоинты предыдущих чанков, индекс чанка). Запрещены timestamps/UUID в промпте; все выборки из памяти — с ORDER BY; все map сортируются по ключу перед рендером. Проверяется unit-тестом «два рендера байт-в-байт идентичны».

request_hash = sha256(book_id | chapter | chunk_index | stage | role | model+sampling | snapshot_id | sha256(rendered_messages)).

Противоречие буквы Р6 («ключ: request-hash» vs «волатильные части исключаются») разрешаю так: хэш считается от запроса, отрендеренного из snapshot, — волатильность заморожена снапшотом, исключать нечего.

3.2 Snapshot контекста джобы

Материализованная запись с собственным snapshot_id: brief_hash, версии промпт-шаблонов per-role, model+sampling per-role, версия чанкера, зафиксированное состояние approved-глоссария (версия-счётчик), style sheet, резюме книги/арок на момент старта джобы. STM в snapshot не входит — он детерминированно пересобирается из чекпоинтов. Батч-подтверждение терминов (Фаза 1) применяется только на границе джоб.

3.3 Деньги: атомарность settle+checkpoint

Дыра «settle прошёл — kill -9 — чекпоинт не записан» = двойная оплата вызова. → Settle и запись сырого ответа — одна SQLite-транзакция в одном файле БД (ledger и чекпоинты живут в проектной БД). Тогда неустранимая потеря — только in-flight вызов (провайдер списал, ответ не дошёл) — это и есть честные «≤1 вызов» приёмки. На старте процесса — recovery-проход: снять зависшие reserve джоб без чекпоинта.

3.4 Ключ TM (Фаза 1, фиксируется сейчас — влияет на схему store)

tm_key = (lang_pair, brief_hash, sha256(нормализованный СЫРОЙ src-чанка)) — нормализация только whitespace/Unicode NFC, до pre-replace терминов (иначе подтверждение термина инвалидирует TM через чёрный ход, что прямо запрещено Р6). prompt_version, model, glossary_version — метаданные записи, НЕ ключ. Открытые вопросы политики инвалидации — [НУЖНО РЕШЕНИЕ] п.2.

3.5 Нейтральный интерфейс LLM (расширения к типам vojo)

  • Message.CacheBoundary bool — конец стабильного блока префикса. Anthropic-адаптер вешает cache_control на соответствующий блок (максимум 4 брейкпоинта); OpenAI-совместимые адаптеры игнорируют (автокэш по префиксу). Единственная точка, где Р5 материализуется в коде, — раскладку задаёт сборщик контекста, не адаптеры.
  • Usage{Prompt, Cached, CacheCreation, Completion, Reasoning} — семантика reasoning per-adapter (xAI аддитивно, OpenAI-спека/ollama subset → 0, Anthropic thinking внутри output → 0) документируется в каждом адаптере, как в доноре.
  • ReasoningEffort: значение "off" — явное выключение thinking; маппится адаптером в провайдер-специфичный флаг. Для нестандартных ручек (GLM thinking.type=disabled, Qwen enable_thinking=false) — extra_body per-модель в models.yaml, мёрджится в JSON запроса OpenAI-совместимым адаптером (эмпирика полигона: thinking GLM-4.6 — таймауты ×3, Gemini молча жрёт max_tokens на thinking).
  • Стриминг в Фазе 0 НЕ реализуется, но интерфейс проектируется под него (второй метод в будущем не ломает адаптеры); длинные генерации закрываются длинными attempt-таймаутами из профиля. См. [НУЖНО РЕШЕНИЕ] п.4.
  • DeepSeek-адаптер парсит оба варианта cache-полей usage (prompt_tokens_details.cached_tokens и prompt_cache_hit_tokens/prompt_cache_miss_tokens) — иначе эксперимент Фазы 0 по кэшу покажет ложный ноль.
  • Ответ транспорта читается через LimitReader (у донора кап есть только в gemini-native).
  • Failover-декоратор портируется, но состав пары primary/fallback задаётся снаружи per-роль (позже per-канал: в канале B fallback = только пермиссивный провайдер или fail loudly — иначе пустой ответ локальной abliterated молча уйдёт в облачную ногу, дыра в изоляции Р4).

3.6 SQLite-рецепт (modernc.org/sqlite)

  • На файл БД два пула: write (MaxOpenConns=1, транзакции BEGIN IMMEDIATE) и read (N соединений).
  • Прагмы на соединение: journal_mode(WAL), busy_timeout(5000), foreign_keys(1), synchronous(NORMAL) (для kill -9-приёмки достаточно; отключение питания — вне модели угроз MVP).
  • Замена advisory-локов: сериализация check-and-reserve через write-пул + BEGIN IMMEDIATE.
  • Регистр/леммы кириллицы нормализуются на стороне Go до записи (NOCASE в SQLite — ASCII-only); сравнения в SQL — только по нормализованным колонкам.
  • FTS5 в горячий путь v1 не тащить (unicode61 не сегментирует CJK; селективной инъекции хватает точных ключей/алиасов по обычным индексам).
  • Файлы БД — только на ext4 (WSL: не /mnt/c — WAL-локи ломаются на drvfs); задокументировать для установок.

3.7 Coverage-гейт v1 (Фаза 1, спека фиксируется сейчас)

Портировать классификацию исходов из eval/refusal_bench.py (hard_refusal / soft_refusal / excision_suspect / ok) 1:1, не изобретать заново. Метрика — символы без пробелов (не токены!); пороги per-пара из эксперимента 02: нижние zh<2.2 / ja<1.4 / en<0.70 (вырезание), верхние >4.2 / >2.6 / >1.4 (аномалия), sent_cov<0.75; минимальная длина применения (калибровка на 5002500 симв.). Сегментация — класс [。!?.!?…] со схлопыванием последовательностей терминаторов и поглощением закрывающих кавычек/скобок. Отдельные ветки по finish_reason: content_filter = refusal; length = обрезание бюджетом → ретрай с бОльшим max_tokens, НЕ эскалация (реальный кейс Gemini из эксп. 02). Blacklist refusal-паттернов — en/ru/zh/ja.

3.8 Конфиги (граница Р2 — закрытие 6 недоопределённостей)

Три файла: book.yaml (translation brief + ссылки) → configs/pipeline-c1.yaml (ядро) → configs/models.yaml (модели/цены/таймауты, с датой проверки). В конфиг ядра входят: состав/порядок стадий, роль→модель, версии промптов, пороги гейтов, режим инъекции глоссария (selective | full_prefix) (Р5 требует обе схемы), токен-бюджеты сборки контекста (инъекция, STM, overlap), лимит регенераций до эскалации, именованные эскалационные цепочки (список моделей; channel-aware фильтр применяет раннер), fan-out N для C2, cache TTL per-стадия. В models.yaml: цены (+cache write/read), профиль таймаутов/ретраев per-модель, extra_body. Зашито в раннер: циклы по главам/чанкам, ветвление по гейтам, механика эскалации/ретраев, семантика «эскалация → re-gate → флаг» (диаграмма pipeline.puml перегейтовку не рисует — реализую re-gate с ограничением попыток, иначе эскалированный вывод коммитится непроверенным).

3.9 Онлайн-факты (проверены 2026-07-04, источники — официальные доки)

  • DeepSeek: deepseek-chat/deepseek-reasoner отключаются 24.07.2026 15:59 UTC (сейчас алиасы deepseek-v4-flash); v4-flash: $0.14 in / $0.28 out / $0.0028 cache-hit, контекст 1M. Пиковых надбавок для V4 официально НЕТ (только пресс-слух) — в конфиг не закладывать, заложить переключаемость тарифа.
  • Anthropic: cache write ×1.25 (5m) / ×2 (1h), read ×0.1; поля cache_creation_input_tokens / cache_read_input_tokens; максимум 4 брейкпоинта; минимальный кэшируемый префикс зависит от модели (Sonnet 5 — 1024 ток., Opus 4.8 — 1024, Haiku 4.5 — 4096) — короче лимита кэш молча не создаётся; thinking биллится как output (полный объём, даже при summarized-отображении). Sonnet 5 промо $2/$10 до 31.08.2026 (кэш write $2.50/5m, hit $0.20; batch $1/$5); Opus 4.8 $5/$25.
  • GLM (Z.AI, международный): флагманы уже GLM-5.1/5.2 ($1.4/$4.4), GLM-5 $1/$3.2 (cached $0.2).
  • Kimi: актуальны kimi-k2.6 ($0.95 in / $4.00 out / $0.16 cache-hit) и k2.7-code; API-домен теперь platform.kimi.ai (301 с platform.moonshot.ai).
  • xAI: grok-4.3 $1.25/$2.50 (1M контекст); Grok 4.1 Fast retired (см. §2 п.4).
  • Gemini: 3.1 Pro (Preview) $2/$12 до 200k промпта, $4/$18 свыше; batch 50%. Эмпирика полигона: judge-роли нужен нативный API (safety_settings через OpenAI-слой не передаются) — нативный Gemini-адаптер планирую Фазой 2 (в vojo есть донорское v1beta-лицо), в Фазе 0 закладываю только место в раскладке адаптеров.

Ловушки телеметрии кэша: TTL 5m может истекать между вызовами одной стадии (латентности чанков 63278 с + гейты) — TTL параметр стадии; телеметрия обязана показывать cache_read=0 при повторных вызовах (тихая инвалидация). DeepSeek-кэш best-effort — экономику считать только по фактическому usage.

4. Мелкие поправки к research/10 (для истории)

  • request_log донора — 46 колонок после v8, не 43; тест-функций 137, не «125+».
  • «Table-driven тесты» — преувеличение: основной стиль донора — сценарные функции + httptest wire-контракты + конкурентные тесты под -race; копирую именно этот стиль.
  • Из config.go переносимы хелперы (~150200 строк: getenv/getSecret *_FILE/fail-fast «problems списком»/ Summary с redaction), а не ~400; YAML-слой конфигов — целиком новый код (yaml.v3 в доноре только для Matrix).
  • telemetry/trace: safego живёт в bot.go — выносится в obs; reqInfo (sender/verbose) заменяется на ось book/chapter/chunk/role; прайсеру нужен явный default-price якорь (у донора — Prices[XAIModel]).

5. [НУЖНО РЕШЕНИЕ] — продублировано в PROGRESS (## Бэкенд)

  1. Премиум-бюджет Р5 «≤2× input-объёма» после токен-калибровки неоднозначен (какой токенизатор, входит ли output; эксп. 01: полный проход уже ~2.8×B o200k-input). Реализую как конфигурируемый $-потолок книги (без зашитого «2×»); формулу должен уточнить владелец/оркестратор.
  2. Политика инвалидации TM сверх brief_hash: (а) входит ли prompt_version в ключ (да = тюнинг промпта обнуляет TM библиотеки; нет = TM молча переживает смену промптов); (б) модель в ключе или метаданных (эскалация перезаписывает запись или дополняет?); (в) смена параметров чанкера = полная инвалидация — трактовать как «смену brief» с показом стоимости? Реализую (а) нет, (б) метаданные+дополняет, (в) да — но это меняет экономику пере-переводов, нужно подтверждение.
  3. Канал B без дешёвого Grok: Grok 4.1 Fast retired; grok-4.3 — $1.25/$2.50 (~9× дороже по input). Основную модель канала B на Фазу 2 надо перевыбрать (кандидаты: open-weights через OpenRouter, DeepSeek, локальная abliterated). Фазу 01 не блокирует (интерим-правило 18+ действует).
  4. Стриминг: план относит к Фазе 3 «нужен только IDE-фронту», но эмпирика полигона требует его как keep-alive транспорта для генераций 63278 с (иначе фейловер ложно роняет здоровую локалку). В Фазе 0 закрываю длинными таймаутами из профиля; предлагаю оркестратору перенести провайдерский стриминг (не SSE-фронт!) в Фазу 12.
  5. Оркестратору (не блокирует): цифры Р5 устарели ~2× против эксп. 01 (Sonnet-проход $46, не $92); задача «проверка cache-hit агрегаторов» назначена и Фазе 0 бэкенда, и бэклогу полигона — предлагаю отдать полигону (у него харнесс сравнения usage), бэкенд потребит результат в конфиге; TMX в диаграммах не помечен «(Фаза 3)»; морфодетектор/CometKiwi/NSFW-классификатор в диаграммах без фазовых пометок; режим онгоинга отсутствует в диаграммах (и не определено, перегоняется ли Analyst при дозагрузке глав).

6. Селфревью Фазы 0 (агентное, 2026-07-04)

Финал Фазы 0: /code-review (8 finder-линз + верификация) + воркфлоу-критики (4 линзы задания: архитектура/ соответствие v2, корректность и деньги, идиоматичность Go, устойчивость к сбоям) с адверсариальной проверкой каждой находки. Подтверждённые исправлены двумя волнами (коммиты dc6ea8ea32ec2e → фиксы v2):

Волна 1 (коммит a32ec2e): общий retry-движок (устранена копипаста Anthropic↔OpenAI, кап Retry-After maxRetryAfterWait 5м, overflow-safe backoff); BilledDecodeError — 2xx с нечитаемым телом консервативно селтлит оценку, не возвращает резерв; пустой оплаченный ответ — громкая остановка, не отравляет редактуру; snapshot-pinning гейт с --resnapshot; flock-владение проектным файлом; цены локальных тегов $0; однопроходный рендер; temperature без omitempty (Anthropic вообще не шлёт sampling — 400); attempt в ключе и схеме.

Волна 2 (эта): snapshot-покрытие расширено (Defaults ratio/min, Title→BriefHash, модельный ExtraBody — правки конфига больше не инвалидируют чекпоинты в обход гейта); PriceForResponse — неизвестный слаг ответа книжится по ЗАПРОШЕННОЙ модели, не по дешёвому якорю (устранён недоучёт премиума); usage-кламп в CostUSD (отрицательные числа не уменьшают committed); zero-usage платный 2xx → settle оценки + WARN; жизненный цикл статуса джобы (не остаётся running на отказе потолка/ошибке клиента); CheckRunnable — fail-fast на нереализованную механику (C2/C3, fan-out>1, роль judge — иначе pipeline-c2.yaml прогнал бы стадии линейно и сжёг деньги Opus на мусор); CheckKeys — preflight API-ключей used-моделей (пустой ключ ловится до reserve, не 401-м после); request-hash — length-prefix полей + счётчик сообщений (устранена NUL-коллизия); нормализация источника BOM/CRLF/NFC (детерминизм хеша между ОС/редакторами); LimitReader-усечение терминально (не ретраит оплаченный >16 MiB); recoverReservations → stderr.

Остаточный техдолг (осознанные ограничения Фазы 0, не блокеры):

  • Дневной потолок — пер-книжный (ledger в файле проекта одной книги): day_usd сбрасывается в UTC-полночь и ограничивает burn-rate одной книги, но не «на оператора за день по всем проектам». Общий потолок оператора потребует shared spend-файла — отложено (раскладка «файл БД на книгу» из Р3/Р6); пометить в UI при мультикниге.
  • Timeout-путь «оплачено, но потеряно»: если провайдер начал биллить 2xx, а соединение оборвалось по таймауту до получения тела, вызов повторяется — недоучёт на один вызов. Это неустранимо для любой системы с retry-on-timeout и укладывается в честные «≤1 вызов» (гарантия Р6 — про kill -9, не про billing на брошенном соединении). BilledDecodeError закрывает случай, когда тело ЧАСТИЧНО дошло; чистый таймаут до тела — нет.
  • cache_ttl ↔ cache_write_per_m: цена записи в кэш в models.yaml задаётся под ОДИН TTL (у нас 5m). Если оператор поставит cache_ttl: 1h, реальная запись стоит ×2 (не ×1.25) — недоучёт. Боевой конфиг 5m совпадает; при переходе на 1h требуется отдельная цена. Валидацию/вторую цену — Фаза 1 (когда включим 1h осознанно).
  • Гонка Runner.clients map: ленивая инициализация клиентов не синхронизирована — безопасна в Фазе 0 (последовательный проход стадий), но параллельные чанки/fan-out Фазы 12 потребуют мьютекса/предзагрузки.