117 lines
22 KiB
Markdown
117 lines
22 KiB
Markdown
# Заметки реализации (сессия «Бэкенд», 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; маппится адаптером в провайдер-специфичный
|
||
флаг. Для нестандартных ручек (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 НЕ реализуется, но интерфейс проектируется под него (второй метод в будущем не ломает
|
||
адаптеры); длинные генерации закрываются дедлайном, ВЫВЕДЕННЫМ из бюджета вызова (⚠ испр. 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:502`=`LOAD, not by the runner`; раннер лишь исполняет уже разрешённый хоп, `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:471` (формула премиум-бюджета — см. `01-decisions.md`, там же сказано, что в коде её НЕТ и почему), `backend/internal/llm/llm.go:106` (провайдерский стриминг — отнесён к Ф2, §3 этого дока), `backend/internal/pipeline/render.go:34` (§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-only `report` и $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_s` 8 вхождений. Премия ×1.25/×2 антропиковская по определению, а провайдера этого вида в конфиге нет (`backend/configs/models.yaml`, греп `удалены вместе с провайдером anthropic`). ⇒ сработать долг не может, пока не появится провайдер с ПЛАТНОЙ записью в кэш; тогда нужны вторая цена и валидация при осознанном переходе на 1h.
|