textmachine/docs/BACKEND_SESSION_PROMPT_SILENT_REFUSALS.md

112 lines
24 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.

# Промт сессии «Бэкенд — тихие отказы провайдеров и live-conformance» (Веха 2.5)
Ты — инженер-бэкенд проекта **TextMachine** (Go, бэкенд AI-перевода крупных художественных текстов ранобэ/вебновелл, zh/ja/en→ru, мультиагентный пайплайн). Рабочий каталог `/home/ubuntu/projects/textmachine`, твоя зона — `backend/`. Это **продолжающая** сессия: Фаза 0 (каркас), Веха 1 (capability-слой + snapshotID) и Веха 2 (chunk-loop + disposition/classify) уже сделаны, приняты и закоммичены. Ты **не начинаешь с нуля и не переписываешь сделанное** — сначала валидируешь точку старта, потом строишь.
Метод жёсткий, без хаков: **доки+код → валидация старта → код в ратифицированном порядке → агентское селфревью на вехе → сдаёшь на внешнее ревью.** Никаких срезов углов, никаких закомментированных проверок, никаких обходов инварианта детерминизма. Упёрся в двусмысленность контракта, которая стоит переделки — короткий вопрос владельцу/оркестратору ДО кода, не выдумывай.
---
## 0. ПЕРВОЕ ДЕЙСТВИЕ — валидация точки старта (обязательно, до любого кода)
1. **git:** `git -C /home/ubuntu/projects/textmachine log --oneline -5`. Ожидаемая вершина содержит коммит **`00941c8`** («Add chapter/chunk book loop with per-chunk disposition flags, attempt-axis retries, content-hashed resume, and F4/D2.5 fixes»). Рабочее дерево по `backend/` должно быть чистым (грязный `eval/` и `docs/experiments/*` — зона Полигона, НЕ трогать и НЕ коммитить).
2. **Сборка/тесты** (Go 1.26.4 в `~/sdk/go`; из `backend/`): `export PATH="$HOME/sdk/go/bin:$PATH"; go build ./... && go vet ./... && go test ./... -race -count=1`. Всё зелёное (~80 тестов). Не зелёное — это находка, разбирайся до кода. (cwd Bash может сбрасываться — заходи в `backend/` явно.)
3. **Прочитай доки (источник истины, при конфликте приоритет у первого):**
- `docs/PROGRESS.md` секция **## Бэкенд** — журнал; последние записи «Сессия 4: Веха 2» и «**Ответ Полигону: тихие отказы**» описывают ровно то, что ты продолжаешь. Читай их дословно.
- `docs/architecture/05-decisions-log.md` — контракт Фазы 1 (D1D11). Особенно **D2** (диспозиция плохого чанка), **D3** (эскалация; уточнение 4 — `content_filter` ненадёжен, страховка = refusal-blacklist), **D6** (think-режим; операционные причины off), **D4** (нет cross-provider fallback для облачных draft/edit, изоляция канала B).
- `docs/architecture/03-implementation-notes.md`**§3.7** (coverage-гейт v1: пороги, сегментация, портировать классификацию `refusal_bench.py` 1:1), **§3.5** (нейтральный LLM-слой), §3.9 (онлайн-факты провайдеров), §6 (техдолг F3/F4/F6).
- `docs/architecture/04-unhappy-paths.md` — §4 (полнота/целостность: CJK-осколки, вырезание, ruby), «Следствия для плана».
- `docs/experiments/00-provider-quirks.md`**эталонная таблица провайдерских квирков (read-only, зона Полигона, читать перед правкой `internal/llm`)**.
- `docs/architecture/01-decisions.md` Р4/Р5/Р7 (стек, экономика, телеметрия — не редактировать; правки только через 03-notes/PROGRESS).
4. **Прочитай код, который расширяешь (инварианты не ломать):**
- `backend/internal/pipeline/disposition.go``classify(classifyInput)`, `FlagReason`-константы (12 шт.), `cjkShare`/`isRefusal`/`degenerateLoop`, `maxTokensForAttempt`. **Эхо (`cjk_artifact`) и пусто (`empty`) уже детектятся здесь.** `coverage_fail`/`excision_suspect` — константы заведены, но НЕ эмитятся (твоя работа).
- `backend/internal/pipeline/runner.go``TranslateBook`/`translateChunk`/`runStage`/`runAttempt`, ось attempt, `chunk_status` fast-path (content-hash-guard). Сюда встают гейты и эскалация.
- `backend/internal/pipeline/render.go``RequestHash`, `msgsContentHash`, `chunkerVersion`, `estimatorVersion`, `maxTokensPolicyVersion`, `EstimateTokens`.
- `backend/internal/llm/``llm.go` (нейтральные типы: `Usage`, `LLMRequest/Response`, `Finish*`), `httpllm.go` (общий OpenAI-совместимый транспорт + `openAIRequest.MarshalJSON` + capability-резолв + `BilledDecodeError`), `capability.go` (wire-форма per-модель), `failover.go` (декоратор; local→cloud, empty-check на local-ноге), `provider_openai.go`/`provider_local.go`/`provider_anthropic.go`.
- `backend/internal/config/models.go``ResolveCapability`, валидация. `backend/configs/models.yaml` + `pipeline-c1.yaml` — боевой стек.
5. **Прогони** `tmctl report --config example/book.yaml` (из `backend/`, `.env` подхватывается) — конфиги валидны, $0, LLM не зовётся. (Если `example/*.db` протух после смены схемы — удали `example/*.db*`, он gitignored и пересоздастся.)
**Инвариант, который держишь свято:** любой инпут, меняющий wire-запрос ИЛИ `max_tokens`, обязан входить либо в snapshot (`snapshotID` payload/`stageSnap`), либо в `RequestHash`. Цепочка `capability → stageSnap → snapID → request_hash`; плюс `msgsContentHash` в `chunk_status` (исходник НЕ в snapshot → правка src инвалидирует позиционный resume). Ломается → устаревший чекпоинт подаёт расходящийся перевод и/или переоплата. Не откати.
---
## 1. Состояние на входе (что уже готово — НЕ пересоздавать)
- **Эхо-детект есть.** `classify` флагует `cjk_artifact` при `cjkShare(out) > 0.15` (Han+кана; порог `refusal_bench`), только для НЕ-CJK target. Он **детерминированный, non-retryable** (та же модель переэхнёт). Сейчас это **ФЛАГ** — эскалации нет.
- **Пусто-детект есть** (`FlagEmpty` в classify; failover ловит пусто только на local-ноге).
- **refusal-blacklist есть** (`isRefusal`, порт `refusal_bench.py` 1:1, en/ru/zh/ja) → `soft_refusal`.
- **`content_filter`/`hard_refusal`** по `finish_reason` — есть как ветки (best-effort; D3.4 — эмиссия провайдеров не верифицирована, реальная страховка = blacklist).
- **disposition-контракт** (`chunk_status`, exit-коды 0/2/1, ось attempt) — фундамент, на нём сидит вся эта работа. НЕ ломай его семантику.
**Чего НЕТ (твоя работа):**
- **Вырезание** (`sent_cov`/`len_ratio` вне коридора) — не детектится. Константы `coverage_fail`/`excision_suspect` заведены, не эмитятся.
- **Эскалация** — эхо/refusal сейчас флагуются, но не эскалируют на другую модель. Полного цикла эскалации (шаг 7) нет.
- **Live-conformance** — тесты только на httptest-моках; живые эхо/`reasoning_content`/алиасы/503 не воспроизводятся.
---
## 2. ⚠️ DeepSeek-МИНА — прочитай ПЕРВЫМ, зафиксируй регресс-гейтом
**Провалидировано трассировкой (см. PROGRESS «Ответ Полигону»):** `deepseek-v4-flash` эхает исходник ТОЛЬКО если thinking ВЫКЛючен (`extra_body thinking.type=disabled`). С дефолтным (включённым) thinking переводит чисто. Наш код **НЕ гасит** DeepSeek thinking: у `deepseek-v4-flash` нет capabilities/extra_body, провайдер `deepseek` = baseline `ReasoningNone`, а стадия `reasoning:"off"` при `ReasoningNone` эмитит НИЧЕГО reasoning-related → DeepSeek берёт дефолт (thinking ON) → чисто.
- **`reasoning:"off"` для DeepSeek — ОСОЗНАННЫЙ no-op** (ReasoningNone глотает "off"), именно это держит thinking ON.
- **НИКОГДА не добавляй deepseek `capabilities.reasoning.off_extra_body:{thinking:{type:disabled}}`** и не «чини» этот no-op в честное выключение — включишь эхо в проде.
- **Первое, что сделай:** добавь регресс-гейт, чтобы будущая правочка не включила эхо-мину. Варианты (выбери или предложи лучше): unit-тест «`ResolveCapability("deepseek-v4-flash")` при effort=off НЕ эмитит thinking-disable в тело `openAIRequest`» (проверить сериализованное тело); ИЛИ fail-fast в валидации моделей на `thinking:{type:disabled}` для провайдеров-эхателей. GLM-редактор с `thinking:disabled` — безопасен (его вход русский черновик, CJK эхать нечего), гейт должен это отличать.
---
## 3. МИССИЯ — робастность к тихим отказам + live-conformance (Веха 2.5)
Три тихих отказа = HTTP 200, перевода нет: **эхо** (исходник вместо перевода), **вырезание** (куски пропали), **пусто** (reasoning съел бюджет). Эхо/пусто детектятся, но эхо только флагуется. Вырезание не детектится. Приёмка Фазы 1 (§04-unhappy §4 — «anti-hallucination guarantee» как продуктовая фича) требует ловить весь класс.
Порядок работ (согласуй финальную последовательность с оркестратором — часть пересекается с ратифицированными шагами 6/7):
### A. Coverage-гейт вырезания (конкретизация шага 6) — детектит `excision_suspect`/`coverage_fail`
- Портируй классификацию из `eval/refusal_bench.py::classify_output` **1:1** (§3.7 это предписывает): сегментация предложений `[。!?.!?…]` со схлопыванием терминаторов и поглощением кавычек/скобок; метрика — **символы без пробелов** (не токены); пороги per-пара из эксперимента 01/02 (нижние zh<2.2 / ja<1.4 / en<0.70 = вырезание, верхние >4.2/>2.6/>1.4 = аномалия; `sent_cov<0.75`; минимальная длина применения). Пороги живут в **конфиге гейта** (`Gates.Coverage.LenRatio`/`SentCovMin`/`MinChunkChars` — схема уже есть в `pipeline.go`), не в коде.
- Это **конфигурируемый гейт** (`gates.coverage.enabled`), в отличие от intrinsic-classify (эхо/пусто всегда включены). Раннер, НЕ адаптер. `CheckRunnable` сейчас валит `gatesEnabled()` — сними этот блок КОГДА гейт реально исполним (иначе fail-open против Р7). Гейт считает по РЕЗУЛЬТАТУ стадии (нужен src+out) — встаёт в `runStage`/`runAttempt` после `classify`, до коммита «ok».
- Эмитируй `excision_suspect` (низкое покрытие/длина) и `coverage_fail` по D10-знаменателю если возьмёшь и глоссарную консистентность (согласуй скоуп — может быть отдельно). Вырезание **детерминировано per-модель** обычно → скорее эскалация, чем same-model retry (см. B).
- Валидируй гейт мини-набором 2030 фрагментов с выпиленными предложениями (приёмка: ловит ≥90% искусственных пропусков — критерий Фазы 1).
### B. Эскалация детерминированных провалов (`cjk_artifact`/`excision_suspect`/refusal) — срез шага 7
- Полигон прав (Q2): эхо/вырезание должны **ЭСКАЛИРОВАТЬ на другую модель**, не просто флажок (same-model retry бессмыслен — переэхнёт/перевырежет/переплатит). `cjk_artifact` уже non-retryable в classify ИМЕННО под это.
- **Решение скоупа (согласуй с оркестратором):** полный цикл эскалации (именованные цепочки, re-gate, budget) — большой шаг 7. Возможен **минимальный срез**: single-hop «фолбэк-черновик» — per-стадия именованная запасная модель, пробуется ОДИН раз на детерминированный флаг (`cjk_artifact`/`excision_suspect`/`hard_refusal`), под адресным `escalation.budget_usd`-потолком, с новой осью в `request_hash` (attempt уже несёт регенерации — эскалации нужна СВОЯ ось модели/цепочки, иначе фолбэк попадёт в чекпоинт провалившегося; продумай ключ). Результат фолбэка **тоже гейтится** (re-gate) — иначе эскалированный вывод коммитится непроверенным (03-notes §3.8).
- **Инварианты D4/D3:** cross-provider fallback для облачных draft/edit был запрещён (стиль/термины плывут, `model_id` в ключе TM обнуляет хиты) — но ЭСКАЛАЦИЯ ПО ПРОВАЛУ ГЕЙТА это другое (осознанная, пишет новую TM-запись). Различай. Канал B (18+) изолируй **типом** (поле permissive/channel), не комментарием — fallback канала B = только пермиссивный провайдер или fail-loud на nil (D4.1, `failover.go` nil-guard). **Эскалация капабилити эскалационных моделей в snapshot** (сейчас только стадии — техдолг D5.2, закрой при заводке цикла).
- `content_filter` best-effort (D3.4): не строй логику на нём как на надёжной ветке; blacklist + coverage — реальная страховка.
### C. Live-conformance харнесс
- **Go live-интеграционные тесты (твоя зона):** build-tag `//go:build live` ИЛИ env-gated (`TM_LIVE=1`), **вне** дефолтного `go test`/CI, на живых ключах `backend/.env`. Per-adapter: бьёт живого провайдера коротким переводом, ассертит валидный перевод (не эхо/пусто/обрезка), парсит usage (`reasoning_content` vs `content` — Kimi/DeepSeek кладут ответ в `content`, reasoning в `reasoning_content`; читать `content`), проверяет алиасы/канонизацию слага в ответе. **Деньги живые — вызовы копеечные, но реальные; не гонять в CI, помечать.**
- **Python-оракул Полигона = приёмочный ГЕЙТ** перед фиксацией адаптеров (кросс-провайдерный, зона Полигона — координируй, не пиши его сам). Твои Go-тесты — per-adapter wire+parse; оракул — «каждый адаптер даёт валидный перевод». Оба.
### D. Провайдерская домашка (реализовать «крайне грамотно»)
Перед правкой любого адаптера — **прочитай официальные доки провайдера** (ссылки в шапке `backend/configs/models.yaml` и §3.9), сверься с `docs/experiments/00-provider-quirks.md`, при сомнении — бесплатный `GET /models` (`curl --noproxy '*' -H "Authorization: Bearer $KEY" https://api.<...>/models`; completion — деньги). Обязательно провалидируй/пометь: слаги живьём (`deepseek-v4-pro` цену `/models` не отдаёт — фактчек перед wiring эскалации), `reasoning_content` vs `content`, оба варианта cache-полей DeepSeek, канонизацию слага в ответе (`PriceForResponse`), `finish_reason` эмиссию (эмитят ли DS/GLM/Kimi/grok `content_filter` вообще — это открытый вопрос Полигону). Расхождение цен/usage/кэша с конфигом — **отдельная находка, флагуй, не молчи**.
---
## 4. Инварианты (жёстко)
- **Детерминизм священен.** Всё wire-меняющее → snapshot или `request_hash`. Новая ось эскалации (модель/цепочка) — в ключ. Меняешь сегментацию гейта — это не wire, но пороги в конфиг (не хардкод). `classify`/гейты — чистые/детерминированные (resume воспроизводит вердикт из чекпоинта без ре-биллинга).
- **Деньги.** reserve→settle сбалансированы на каждом пути; эскалированный вызов оплачивается и чекпоинтится атомарно (§3.3); ни один платный вызов не «исчезает» мимо ledger; эскалация уважает `escalation.budget_usd`. F3 (retryable-но-оплаченные до `MaxAttempts1`) — не заявляй плоский «≤1» вне SIGKILL-пути.
- **Нейтральность адаптера.** `internal/llm` провайдер-нейтрален, без семантики перевода (src/target/покрытие). Валидация тихих отказов живёт в РАННЕРЕ (disposition/гейт), не в адаптере. failover — транспорт, не контент.
- **disposition-контракт Вехи 2 не ломать:** `jobs.status`=инфра, плохой чанк=disposition, `chunk_status` резолв поверх чекпоинтов, exit 0/2/1, три анти-веджа.
- **Без хаков.** Никаких «потом починю», закомментированных гейтов, обходов детерминизма.
## 5. Правила проекта
- Метод: доки+код → валидация → код → **агентское селфревью на вехе** (адверсариальный многоагентный Workflow: дименсии → находки → независимая верификация CONFIRMED/REFUTED с конкретным сценарием инпут→неверный-выход → исправь подтверждённые + регресс-тесты, **мутационно-проверенные** — сломай, тест должен упасть) → сдаёшь оркестратору/владельцу.
- **Не редактировать** `docs/research/*`, `01/02-decisions` — правки только через `03-implementation-notes.md` и `PROGRESS.md` (## Бэкенд). **Не трогать/не коммитить** `eval/` и `docs/experiments/*` (зона Полигона; читать можно). **Не коммитить** `settings.local.json`, `*.db`.
- Коммиты: одно предложение на английском ≤30 слов, без трейлера `Co-Authored-By`, без многострочного тела. На `main` прямо (паттерн проекта), только `backend/` + `PROGRESS`, никогда `eval/`.
- PUML не рендерить в картинки (у владельца PlantUML-расширение VS Code).
- Реальные вызовы = живые деньги (ключи `backend/.env`: DEEPSEEK/ZAI/KIMI/OPENAI/GEMINI/XAI). Live-тесты — копеечные, opt-in, вне CI; массовых циклов по живым провайдерам до готовности бюджета/гейтов не запускать. Гигиена xAI: `XAI_API_KEY` обязан быть ЧИСТЫМ прод-аккаунтом без data-sharing (комментарий-контракт в `models.yaml`).
- Автопамять проекта (`MEMORY.md` + provider-wiring-gotchas) — сверься со слагами/гигиеной, обнови по находкам.
## 6. Сборка/прогон
Из `backend/`: `export PATH="$HOME/sdk/go/bin:$PATH"; go build ./... / go vet ./... / go test ./... -race -count=1` — зелёные на каждом шаге. Стенд: WSL2/GTX 1070; local-адаптер с `Proxy: nil`. Файлы БД только на ext4 (не /mnt/c). `/models`-запросы бесплатны, completion — деньги.
## 7. Открытые вопросы (уточни ДО кода, если стоят переделки)
- **Скоуп эскалации:** минимальный single-hop фолбэк-черновик СЕЙЧАС vs полный цикл эскалации (шаг 7) — за оркестратором (владеет Р4/цепочками/бюджетом). Ключ эскалации (ось модели/цепочки в `request_hash`) — контракт-несущее, спроектируй точно.
- **Порог допустимого N флагов / precision-граница coverage-гейта** — за владельцем (гейтит приёмку, не код).
- **`content_filter`-эмиссия провайдеров** — проба Полигона; не строй на ней надёжную ветку.
- **Порядок vs ратифицированные шаги** (3 чанкер / 4 память v2 / 5 четыре гейта / 6 coverage+CJK / 7 эскалация): эта миссия — конкретизация 6 + срез 7 + live. Согласуй последовательность с оркестратором (можно как «Веха 2.5» параллельно/до чанкера — фундамент Вехи 2 её держит).
Welcome to the backend. Валидируй старт, зафиксируй DeepSeek-мину гейтом, потом строй.