22 KiB
Заметки реализации (сессия «Бэкенд», 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 (донорский отчёт — ../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 подтверждён. Нормализация 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; маппится адаптером в провайдер-специфичный флаг. Для нестандартных ручек (GLMthinking.type=disabled, Qwenenable_thinking=false) —extra_bodyper-модель в models.yaml, мёрджится в JSON запроса OpenAI-совместимым адаптером (эмпирика полигона: thinking GLM-4.6 — таймауты ×3, Gemini молча жрёт max_tokens на thinking).- Стриминг в Фазе 0 НЕ реализуется, но интерфейс проектируется под него (второй метод в будущем не ломает
адаптеры); длинные генерации закрываются дедлайном, ВЫВЕДЕННЫМ из бюджета вызова (⚠ испр. 10.09, пак строки 360:
backend/internal/llm/attemptcut.go:269=func (p RetryProfile) deriveDeadline— профиль даёт полattempt_s, потолокattempt_max_sи скорость, а само время считается отmax_tokensпопытки;attempt_sстал ПОЛОМ, а не значением); провайдерский стриминг отнесён к Ф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; минимальная длина применения (калибровка на 500–2500 симв.). Сегментация — класс
[。!?.!?…] со схлопыванием последовательностей терминаторов и поглощением закрывающих кавычек/скобок.
Отдельные ветки по 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), лимит регенераций до эскалации,
именованные эскалационные цепочки (список моделей; ⚠ испр. 10.09: какую цепочку берёт прогон, решает ЗАГРУЗКА по content_policy, а не раннер — backend/internal/config/pipeline.go:529=LOAD, not by the runner (⚠ испр. 17.09: было :502); раннер лишь исполняет уже разрешённый хоп, backend/internal/pipeline/escalation.go:125=st.ResolvedHop. Слово «channel» отставлено — backend/internal/config/pipeline.go:266=LegacyChannel is the RETIRED), 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 кэша истекает между вызовами одной стадии (латентности чанков 63–278 с + гейты) ⇒ TTL — параметр стадии, а телеметрия обязана показывать cache_read=0 при повторных вызовах, иначе тихая инвалидация невидима · DeepSeek-кэш best-effort — экономику считать только по фактическому usage.
5. [НУЖНО РЕШЕНИЕ] — СНЯТО, всё развязано
⚠ §5 СНЯТ — а код на него ещё ссылается, поэтому вот куда он уехал (заглушка, испр. 10.09, по образцу заглушки §2 выше). Три не-тестовых сайта продолжают адресовать эту секцию:
backend/internal/config/pipeline.go:498=a defect flips the chunk to flagged(⚠ испр. 17.09: было :471) (формула премиум-бюджета — см.01-decisions.md, там же сказано, что в коде её НЕТ и почему),backend/internal/llm/llm.go:106=JSONOnly bool(провайдерский стриминг — отнесён к Ф2, §3 этого дока),backend/internal/pipeline/render.go:34=explicit re-translation, not a silent cache miss(§3.4 того же дока). ⇒ читающий из кода приходит сюда и находит указатель, а не пустоту.
Пять вопросов 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) и независимое внешнее ревью приняты, блокеров ноль; хроника волн и диспозиция находок F1–F8/D1–D3 — ../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/BACKLOG.md, греп Два техдолга движка без строки трекера):
- Дневной потолок — пер-книжный (ledger в файле проекта одной книги):
day_usdсбрасывается в UTC-полночь и ограничивает burn-rate одной книги, а не «оператора за день по всем проектам»; общий потолок потребует shared spend-файла (раскладка «файл БД на книгу» из Р3/Р6). Платформенная половина —PD-157. prices_checked(120 д) гейтит read-onlyreportи $0-resume (F6): платных вызовов они не делают, но падают на устаревших ценах. ⚠ Приёмка D39.134: этот же гейт работает ДЕТЕКТОРОМ самолечения деплоя — снимать его бездумно нельзя.cache_ttl↔cache_write_per_m⚠ испр. 10.09 — посылка долга на дереве не выполняется: цена записи не «задана под 5m», а НЕ ЗАДАНА ВОВСЕ. Замер поbackend/configs/models.yaml(371 строка):cache_write_per_m— 11 вхождений, ненулевых 0;cache_ttlв этом файле — 0 вхождений (боевойcache_ttl: "5m"живёт вbackend/configs/pipeline-c1.yaml), контроль —attempt_s8 вхождений. Премия ×1.25/×2 антропиковская по определению, а провайдера этого вида в конфиге нет (backend/configs/models.yaml, грепудалены вместе с провайдером anthropic). ⇒ сработать долг не может, пока не появится провайдер с ПЛАТНОЙ записью в кэш; тогда нужны вторая цена и валидация при осознанном переходе на 1h.