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

20 KiB
Raw Blame History

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

⚠ Точка-во-времени 04.07 (ревизия D31). §3-контракты Фазы 0 — живой референс бэкенда (request_hash охраняется golden-гардом D23); §5 «[НУЖНО РЕШЕНИЕ]» — все пункты развязаны D-логом (премиум-бюджет → D11/exp08; TM-политика → Р6/D-лог; канал B → D14.1/D19.1; стриминг → keep-alive Ф2); онлайн-факты §3.9 датированы 04.07 — цены/слаги сверять с models.yaml/quirks.

СТАТУС-АПДЕЙТ 25.07 (оркестратор №7): код-география §3 перечёркнута паком-15 (D39.23). Контракты §3 живы семантикой, но монолита internal/pipeline больше нет: движок разложен на internal/{text,seed,chunk,miner,checks,membank} + композит-корень pipeline; конфиг-слой §3.8 расширен configs/pairs/<пара> + configs/langpacks/ + промпт-конвенцией prompts/<пара>/<роль>.md и строгим KnownFields-декодом (паки 15/16). Что из §6-техдолга закрыто — перечислено там же, с грепами; снапшот-дисциплина и golden-гард D23 живы в новой раскладке (golden байт-в-байт, парити EXACT — D39.23).

Вердикт шага 0 — перевалидации решений v2 глазами реализатора перед Фазой 0 (метод: построчное чтение донорского кода vojo + четыре критик-линзы, ~940k токенов; донорский отчёт — ../research/10-vojo-ai-bot-review.md). Живое здесь — §3: контракты, которые Фаза 0 приняла на себя. Разделы «общий вердикт исполнимости» и «упущения передавшей сессии» СНЯТЫ как прожитые: их предмет либо стал контрактом §3 (cache-write в Usage и формула стоимости — §3.5 · таймауты/ретраи параметрами клиента и чтение Retry-After вместо трёх захардкоженных · мини-раннер C1), либо ушёл в свои дома — прокси-ловушка (Go stdlib не проксирует loopback, опасны не-loopback локальные адреса вроде WSL-gateway 172.x; в коде llm.NoProxyClient) и мёртвый Grok 4.1 Fast живут в docs/experiments/00-provider-quirks.md и backend/configs/models.yaml, оценка переписывания store.go и счётчики донора — в ../research/10-vojo-ai-bot-review.md, хроника — ../archive/PROGRESS-2026-07-04-10.md.

§2 СНЯТ — а код на него ещё ссылается, поэтому вот куда он уехал (заглушка, испр. 02.09): п. 2 (таймауты/ретраи параметрами клиента, чтение Retry-After вместо трёх захардкоженных) → параметры клиента в backend/internal/llm/httpllm.go, греп 03-implementation-notes §2 item 2; п. 5 (single-connection write pool + BEGIN IMMEDIATE) → §3.6 «SQLite-рецепт» ниже, носитель кода — backend/internal/store/store.go, греп 03-implementation-notes §2 item 5. Комментарии в коде НЕ трогались — их адресат теперь описан здесь.

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-глоссария (версия-счётчик) (→ superseded D8: content-hash материализации, НЕ счётчик — drift-proof; реализовано, memory_f1_test), style sheet, резюме книги/арок на момент старта джобы. STM в snapshot не входит — он детерминированно пересобирается из чекпоинтов. Батч-подтверждение терминов (Фаза 1) применяется только на границе джоб.

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

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

3.4 Ключ TM (Фаза 1)

Действующий ключ — ратифицированный оркестратором (chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash): model_id и prompt_version ВХОДЯТ в ключ, а не в метаданные (тюнинг промпта и смена модели легитимно инвалидируют TM; перечанкование — by design). Носитель — Р6, 01-decisions.md, греп Ключ TM подтверждён; строить по нему, а не по первой редакции этого параграфа (она клала model/prompt_version в метаданные и снята). Нормализация src перед хэшем — только whitespace/Unicode NFC и до pre-replace терминов, иначе подтверждение термина инвалидирует TM через чёрный ход (прямой запрет Р6). В Фазе 0 это неважно: cross-book TM не реализован, есть только checkpoint/resume request-hash, который model+prompt_version уже включает через snapshot.

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-таймаутами из профиля; провайдерский стриминг отнесён к Ф2.
  • DeepSeek-адаптер парсит оба варианта cache-полей usage (prompt_tokens_details.cached_tokens и prompt_cache_hit_tokens/prompt_cache_miss_tokens) — иначе эксперимент Фазы 0 по кэшу покажет ложный ноль.
  • Ответ транспорта читается через LimitReader (у донора кап есть только в gemini-native).
  • Failover-декоратор донора СНЯТ, а не портирован (backend/internal/llm/failover.go нет): изоляцию канала B держит механизм лейблов — content_labels книги ⊆ accepts_labels модели, действия route|allow|terminal, fail-closed на пустых лейблах (D39.26 дизайн · D39.27 стройка · D39.28 дельта).

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)

Цены, слаги и даты проверки живут ТОЛЬКО в backend/configs/models.yaml (поле prices_checked), провайдерские ловушки — в docs/experiments/00-provider-quirks.md (там же атрибуция находок 04.07: Grok-retired · cache-write Anthropic · per-attempt-60 с · localhost-прокси). Июльский срез цен снят как протухший — с 16.08 действует пере-пин D39.179 п.1 (множитель ×4.47 к июльским деньгам).

Что из того среза — МЕХАНИКА, а не цена, и потому остаётся здесь (проверено 04.07 по официальным докам вендоров): минимальный кэшируемый префикс зависит от модели (короче лимита кэш молча НЕ создаётся, у Anthropic — 1024/4096 токенов по моделям) · thinking биллится как output полным объёмом, даже когда отображается summary · TTL кэша истекает между вызовами одной стадии (латентности чанков 63278 с + гейты) ⇒ TTL — параметр стадии, а телеметрия обязана показывать cache_read=0 при повторных вызовах, иначе тихая инвалидация невидима · DeepSeek-кэш best-effort — экономику считать только по фактическому usage.

4. Мелкие поправки к research/10 — СНЯТЫ

Уточнения счётчиков донора относились к отчёту ../research/10-vojo-ai-bot-review.md и к коду, которого в этом репозитории нет; мерило порта — сам порт.

5. [НУЖНО РЕШЕНИЕ] — СНЯТО, всё развязано

Пять вопросов 04.07 закрыты, карта развязок — в баннере вверху (тела D11/D14/D19/D23 — в слайсе ../archive/architecture/05-decisions-D1-D38.md); пятый, не попавший в карту, — пункты оркестратору (фазовые пометки диаграмм, владелец задачи cache-hit), отработан арх-ресетом D39. Формулировки вопросов — ../archive/PROGRESS-2026-07-04-10.md.

6. Что осталось от Фазы 0 живым долгом

Селфревью Фазы 0 (скелет 7954325 → волна 1 e861541 → волна 2 214e005) и независимое внешнее ревью приняты, блокеров ноль; хроника волн и диспозиция находок F1F8/D1D3 — ../archive/PROGRESS-2026-07-04-10.md (§«Фаза 0 принята»). Из тогдашнего техдолга ЗАКРЫТО: гонка Runner.clients (клиенты строятся eager-проходом ДО волн, дальше map read-only — internal/pipeline/runner.go, греп the clients map is READ-ONLY in the waves; D39.13) · F4 «finish=length с обрезанным черновиком» (флаг диспозиции FlagLengthinternal/pipeline/disposition.go, греп FlagLength FlagReason) · F3-класс оплаченных ретраев (кап пере-биллинга: второй нечитаемый оплаченный 2xx ТЕРМИНАЛЕН, ре-биллы капятся одним — internal/llm/httpllm.go, греп Cap billed-decode re-bills at ONE) · D1/D2 (билингв-редактор ратифицирован D30.1 — требование «убрать {{text}}» ОТМЕНЕНО, не исполнять; per-chunk skip+flag построен диспозиция-классификатором) · D3 (изоляция канала B: failover.go удалён, механизм лейблов content_labels×accepts_labels — D39.26/27/28).

Живой остаток (носители — бэклог-строка 146 docs/PROGRESS.md, греп Два техдолга движка без строки трекера):

  • Дневной потолок — пер-книжный (ledger в файле проекта одной книги): day_usd сбрасывается в UTC-полночь и ограничивает burn-rate одной книги, а не «оператора за день по всем проектам»; общий потолок потребует shared spend-файла (раскладка «файл БД на книгу» из Р3/Р6). Платформенная половина — PD-157.
  • prices_checked (120 д) гейтит read-only report и $0-resume (F6): платных вызовов они не делают, но падают на устаревших ценах. ⚠ Приёмка D39.134: этот же гейт работает ДЕТЕКТОРОМ самолечения деплоя — снимать его бездумно нельзя.
  • cache_ttlcache_write_per_m: цена записи в кэш в models.yaml задана под ОДИН TTL (боевой 5m). При cache_ttl: 1h реальная запись стоит ×2, а не ×1.25 — недоучёт; вторая цена/валидация нужны при осознанном переходе на 1h.