textmachine/docs/prompts/done/BACKEND_SESSION_PROMPT_SILENT_REFUSALS.md

24 KiB
Raw Blame History

Промт сессии «Бэкенд — тихие отказы провайдеров и 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.goclassify(classifyInput), FlagReason-константы (12 шт.), cjkShare/isRefusal/degenerateLoop, maxTokensForAttempt. Эхо (cjk_artifact) и пусто (empty) уже детектятся здесь. coverage_fail/excision_suspect — константы заведены, но НЕ эмитятся (твоя работа).
    • backend/internal/pipeline/runner.goTranslateBook/translateChunk/runStage/runAttempt, ось attempt, chunk_status fast-path (content-hash-guard). Сюда встают гейты и эскалация.
    • backend/internal/pipeline/render.goRequestHash, 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.goResolveCapability, валидация. 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-мину гейтом, потом строй.