231 lines
28 KiB
Markdown
231 lines
28 KiB
Markdown
# Заметки реализации (сессия «Бэкенд», 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-вызов при вызовах в 10–300 с — тривиальная нагрузка).
|
||
- Р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-токенов 30–44%. research/10 это предлагал (§3 п.1),
|
||
но в обязательства Фазы 0 в плане не попало. → В `Usage` добавляю `CacheCreationTokens`, в `ModelPrice` —
|
||
`CacheWritePerM`; формула: `(prompt−cached)·in + cached·cacheRead + cacheCreation·cacheWrite + (completion+reasoning)·out`.
|
||
2. **Скрытый третий таймаут донора**: полигон знал про 45 с (нога failover) — но в транспорте httpllm.go:214
|
||
захардкожен ещё и per-attempt 60 с, а maxTry=3. С чанками 63–278 с любая генерация >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; минимальная длина применения (калибровка на 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), лимит регенераций до эскалации,
|
||
**именованные эскалационные цепочки** (список моделей; 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 может истекать между вызовами одной стадии (латентности чанков 63–278 с +
|
||
гейты) — 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 переносимы хелперы (~150–200 строк: 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). Фазу 0–1 не блокирует (интерим-правило 18+ действует).
|
||
4. **Стриминг**: план относит к Фазе 3 «нужен только IDE-фронту», но эмпирика полигона требует его как
|
||
keep-alive транспорта для генераций 63–278 с (иначе фейловер ложно роняет здоровую локалку). В Фазе 0
|
||
закрываю длинными таймаутами из профиля; предлагаю оркестратору перенести провайдерский стриминг
|
||
(не SSE-фронт!) в Фазу 1–2.
|
||
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, устойчивость к сбоям) с адверсариальной проверкой
|
||
каждой находки. Подтверждённые исправлены **двумя волнами** (коммиты `dc6ea8e` → `a32ec2e` → фиксы 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 Фазы 1–2 потребуют мьютекса/предзагрузки.
|