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

117 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Заметки реализации (сессия «Бэкенд», 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-техдолга закрыты: гонка `Runner.clients` (WS1a, D39.13) · F4 — усечённый length флагуется disposition-классификатором · F3-класс — кап пере-биллинга пака-12; снапшот-дисциплина и 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`.
## 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-таймаутами из профиля (см. §5: провайдерский стриминг отнесён к Ф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 — СНЯТЫ (были помечены «для истории»)
Уточнения счётчиков донора (46 колонок `request_log`, 137 тест-функций, стиль тестов, объём переносимого из `config.go`, раскладка telemetry/trace) относились к отчёту `../research/10-vojo-ai-bot-review.md` и к коду, которого в этом репозитории нет; сам порт давно построен и мерилом служит уже он.
## 5. [НУЖНО РЕШЕНИЕ] — СНЯТО, всё развязано
Пять вопросов 04.07 закрыты и повторного решения не требуют (карта в баннере вверху): премиум-бюджет Р5 D11 + эксп-08 · политика инвалидации TM Р6, `01-decisions.md`, греп `Ключ TM подтверждён` · канал B без дешёвого Grok D14.1/D19.1, состав и цены живут в `backend/configs/models.yaml` · стриминг как keep-alive закрыт длинными attempt-таймаутами профиля, провайдерский стриминг отнесён к Ф2 · пункты оркестратору (фазовые пометки диаграмм, владелец задачи cache-hit) отработаны арх-ресетом D39. Формулировки вопросов `../archive/PROGRESS-2026-07-04-10.md`.
## 6. Что осталось от Фазы 0 живым долгом
Селфревью Фазы 0 (две волны, коммиты `dc6ea8e` `a32ec2e`) и независимое внешнее ревью **приняты, блокеров ноль**; хроника волн и диспозиция находок 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 с обрезанным черновиком» (флаг диспозиции `FlagLength` `internal/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_ttl` `cache_write_per_m`**: цена записи в кэш в models.yaml задана под ОДИН TTL (боевой 5m). При `cache_ttl: 1h` реальная запись стоит ×2, а не ×1.25 недоучёт; вторая цена/валидация нужны при осознанном переходе на 1h.