Scope CheckKeys to reachable models so backend runs on two keys; escalation keys required only when gates enabled

This commit is contained in:
Claude (backend session) 2026-07-04 15:49:27 +03:00
parent 214e005155
commit 56670e3d67
5 changed files with 96 additions and 19 deletions

View file

@ -1,10 +1,25 @@
# Скопируй в backend/.env (gitignored) и заполни. tmctl подхватывает .env из
# каталога book.yaml и из CWD; уже установленные переменные окружения не
# перетираются.
DEEPSEEK_API_KEY=
ZAI_API_KEY=
KIMI_API_KEY=
ANTHROPIC_API_KEY=
#
# Два набора моделей в проекте служат РАЗНЫМ задачам:
# backend/.env — продакшн-стек Р4, ФАЗОВО (нужно только то, что реально
# зовётся в текущей фазе);
# eval/.env — бенчмарк-сеть сессии «Полигон» (широкий набор провайдеров
# для замеров отказов/токенов), НЕ обязан совпадать с продакшн-стеком.
# --- НУЖНЫ СЕЙЧАС (Фаза 0: пайплайн C1 draft→edit) ---
DEEPSEEK_API_KEY= # черновик — DeepSeek V4 Flash (лучший zh-токенизатор, дешевле всех)
ZAI_API_KEY= # редактура — GLM-5 (ru-стиль), международный контур z.ai
# --- ОПЦИОНАЛЬНО, Фаза 1+ (эскалация; зовутся только при включённых гейтах) ---
# KIMI_API_KEY= # альтернативный редактор / эскалация канала adult (Р4)
# ANTHROPIC_API_KEY= # эскалация/судья SFW, ТОЛЬКО прямой контур; в RU-контуре
# экономически недоступен (Р5). Не нужен для Фазы 0.
# Держи пустым, пока eval не подтвердит SFW-поведение
# Sonnet-5 (эскалация «стерильного» канала — открытый
# вопрос к полигону, см. PROGRESS).
# LOG_LEVEL=debug
# LOG_FORMAT=json
# LOG_LLM_BODIES=1 # логировать сырые тела LLM-обменов (только с LOG_LEVEL=debug)

View file

@ -59,13 +59,23 @@ func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) {
if err := m.CheckKeys(cloudPipe); err != nil {
t.Fatalf("present key must pass: %v", err)
}
// Escalation-цепочки тоже учитываются.
escPipe := &Pipeline{
// Эскалационные ключи НЕ требуются, пока гейты выключены (эскалация
// недостижима — Фаза 0): local-стадия + облачная цепочка при gates off.
t.Setenv("TEST_CLOUD_KEY", "")
escOffPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
}
t.Setenv("TEST_CLOUD_KEY", "")
if err := m.CheckKeys(escPipe); err == nil {
t.Fatal("escalation chain model without a key must fail-fast")
if err := m.CheckKeys(escOffPipe); err != nil {
t.Fatalf("escalation keys must not be required while gates are off: %v", err)
}
// С включённым гейтом эскалация достижима → ключ цепочки обязателен.
escOnPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
Gates: Gates{Coverage: CoverageGate{Enabled: true}},
}
if err := m.CheckKeys(escOnPipe); err == nil {
t.Fatal("escalation chain model without a key must fail-fast when gates are enabled")
}
}

View file

@ -185,20 +185,29 @@ func (p Provider) APIKey() string {
return os.Getenv(p.APIKeyEnv)
}
// CheckKeys verifies that every non-local model REFERENCED by the pipeline has
// its provider API key resolvable in the environment — preflight fail-fast,
// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота
// (находка ревью). Проверяются только используемые модели: local-only прогон
// не должен падать на незаполненных облачных ключах. Escalation-цепочки тоже
// учитываются — их модели вызываются в Фазе 1.
// CheckKeys verifies that every non-local model the pipeline can ACTUALLY call
// has its provider API key resolvable in the environment — preflight fail-fast,
// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота.
//
// Проверяются только достижимые модели, не весь Р4-стек:
// - модели СТАДИЙ — всегда (зовутся на каждом чанке);
// - модели ЭСКАЛАЦИОННЫХ цепочек — ТОЛЬКО если включён хоть один гейт:
// эскалация запускается исключительно по провалу гейта, поэтому при
// gates.*.enabled=false (Фаза 0) она недостижима и её ключи не нужны.
// Без этого условия перевод одного чанка (DeepSeek+GLM) требовал бы ещё
// ключи Anthropic/Kimi из дефолтных цепочек, которые в Фазе 0 не зовутся.
//
// local-only прогон облачных ключей не требует.
func (m *Models) CheckKeys(pipe *Pipeline) error {
needed := map[string]struct{}{}
for _, st := range pipe.Stages {
needed[st.Model] = struct{}{}
}
for _, chain := range pipe.Escal.Chains {
for _, mdl := range chain {
needed[mdl] = struct{}{}
if pipe.gatesEnabled() {
for _, chain := range pipe.Escal.Chains {
for _, mdl := range chain {
needed[mdl] = struct{}{}
}
}
}
var problems []string

View file

@ -93,6 +93,12 @@ type Fanout struct {
Candidates int `yaml:"candidates"`
}
// gatesEnabled reports whether any QA-gate is on. Эскалация запускается только
// по провалу гейта, поэтому это сигнал «эскалация вообще достижима» (Фаза 1+).
func (p *Pipeline) gatesEnabled() bool {
return p.Gates.Coverage.Enabled
}
// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT
// implement — раздельно от schema-валидации (LoadPipeline), чтобы скелеты C2/C3
// парсились и валидировались как схема, но НЕ исполнялись молча. Без этого

View file

@ -107,6 +107,38 @@ brief_hash, chunker/estimator version); перечанкование инвал
timeout-путь «оплачено-но-потеряно» (≤1 вызов, укладывается в Р6); cache_ttl↔cache_write под один TTL (5m);
гонка `Runner.clients` (безопасна при последовательном проходе, мьютекс — к fan-out Фазы 12).
**Ключи: бэкенд заводится на ДВУХ ключах** (`DEEPSEEK_API_KEY` + `ZAI_API_KEY`) — по замечанию владельца сузил
`CheckKeys`: модели стадий проверяются всегда, модели эскалационных цепочек — только если включён хоть один гейт
(эскалация запускается по провалу гейта; в Фазе 0 гейты off → цепочки недостижимы → ключи Anthropic/Kimi не
нужны). Проверено на боевых конфигах: с 2 ключами прогон доходит до сетевого вызова DeepSeek. `.env.example`
размечен «нужны сейчас / Фаза 1+ опционально». Anthropic/Kimi для Фазы 0 не требуются.
### Вопросы от бэкенда — сессии «Полигон» и оркестратору (04.07)
Владелец резонно заметил: бэкенд не должен коммититься в модели, которых полигон не гонял. Прежде чем в Фазе 1
включать эскалацию и фиксировать редактора-дефолт, нужны замеры:
1. **Полигону — Anthropic на «стерильном» канале (SFW).** Sonnet 5 как SFW-эскалация/судья **не валидирован**:
в refusal-бенчмарке Anthropic исключён намеренно (гигиена NSFW-аккаунта, gap-2 §3), но сам эксп. 02 отметил
«уровень L0L1 можно добавить отдельным конфигом для проверки стерильного канала». Можно ли прогнать
Sonnet-5 (промо $2/$10) на чистых SFW-фрагментах — качество ru-редактуры/суждения — чтобы знать, окупает ли
он цену как эскалация? Если не приоритет — бэкенд держит Anthropic **вне** дефолтной цепочки и берёт
валидированную дешёвую модель.
2. **Полигону — качество редактора ru-стиля.** Эксп. 02 мерил роль ЧЕРНОВИКА (отказы/вырезания), но не роль
РЕДАКТОРА. Дефолт C1 — GLM-5 на редактуре — стоит на утверждении Р4, без замера. Планируется ли маленькое
сравнение GLM-5 vs Kimi-K2.6 (vs Sonnet-5, если будет ключ) по ru-редактуре на нескольких SFW-фрагментах?
От этого зависит дефолт редактора Фазы 1.
3. **Оркестратору — эскалационный дефолт в RU-контуре.** Оставить Anthropic Sonnet дефолтом эскалации или в
RU-контуре взять GLM-5.1/Gemini (Sonnet — только прямой контур, где per-book-премиум оправдан)? Это правка
Р4/экономики; бэкенд — только исполнитель конфига.
4. **Оркестратору — per-role provider-fallback?** Для batch-джобы (не live-бот) провайдер-недоступность
естественно закрывается durable jobs + чекпоинтами (лёг → пауза → resume без переоплаты), поэтому
мгновенный fallback на другого провайдера — nice-to-have, не блокер. Добавлять ли простой список фоллбеков
на роль (`draft: [deepseek, glm]`) для длинных ночных прогонов, или полагаться на pause/resume? Решение — за
оркестратором (владеет Р4/экономикой).
Не блокирует Фазу 0 (принята на DeepSeek+Z.ai); ответы нужны к старту Фазы 1 (эскалация, дефолты ролей).
Следующее: Фаза 1 (перевод книги целиком) — чанкер, банк памяти v1, гейты (пороги полигона готовы), режим онгоинга.
## Полигон
@ -128,10 +160,15 @@ timeout-путь «оплачено-но-потеряно» (≤1 вызов, у
- **Эксперимент 03 (задача 3) — первый замер + эталон** → [experiments/03-local-stand.md](experiments/03-local-stand.md). По п.3 вводных: (а) 30b-a3b в WSL **влезает** (mmap, без OOM), но на ollama даёт лишь **10 tok/s** — «Расхождение» с research/06 (2530 обещаны через llama.cpp `--n-cpu-moe`; ставится в фоне — перемерить; Р4 менять пока не надо: рекомендация там и так llama-server); частично виноват и лимит WSL RAM 20GB — поднят до 26GB (`.wslconfig`, вступит после рестарта WSL); (б) 8b @ 27 tok/s (6.6GB VRAM) — на роли скрининга/экстракции годен. **Эталон DeepSeek снят: разрыв качественный** — API даёт корректные реалии («ворота Расёмон на улице Судзаку», «Новогоднее жертвоприношение») за ~$0.0005/фрагмент там, где обе локалки галлюцинируют («Рождественская дверь», «петухи»); NSFW-фрагменты ещё не гонялись. Для бэкенда: лег-таймаут vojo 45 с несовместим с чанками (63278 с) — нужен свой профиль + стриминг. План расширения пула: ваниль qwen3:8b (цена абляции) → Qwen3.5-9B → RuadaptQwen3 (докачиваются в фоне).
- Бэклог принят: проверка проброса cache-hit агрегаторами (п.4, нужны ключи) — в шагах ниже. Задачи 45 не начаты (bge-m3 уже на стенде).
### 2026-07-04 — Сессия 2 полигона (после рестарта WSL с RAM 26GB)
- **llama.cpp `--n-cpu-moe` для 30b-a3b — перемерено, «Расхождение» с research/06 подтверждено**: потолок генерации на GTX 1070 **~13.5 tok/s** (не 2530). Таблица конфигов в [03](experiments/03-local-stand.md): ollama 10 → llama.cpp `--cpu-moe` 11.2 → `--n-cpu-moe 33` 13.5 (VRAM 2.9→7.8GB, prefill 105→208). Узкое место — пропускная способность CPU-RAM (DDR4/Pascal), не рантайм; llama.cpp честно лучше ollama (+35% ген., ×23 prefill), но 30b-a3b на этом железе — только фоновые роли (~2.5 мин/главу). Скрипт: `eval/llama_moe_bench.sh`.
- **Замер 4 новых dense-моделей** (ваниль qwen3:8b / qwen3.5:9b / +abliterated / ruadapt) — готово, таблица в [03](experiments/03-local-stand.md). Три вывода: (1) **абляция бесплатна** — qwen3.5 vanilla vs abliterated идентичны по скорости и SFW-качеству → 18+-роль можно на abliterated без штрафа; (2) поколение 3.5>3 умеренно (лучше понимание, 25% токенов, 10% скорости); (3) **RuAdapt: токен-выигрыш подтверждён (40% ru-токенов), но перевод развалился** (羅生門→«дерево с колокольчиками») → редактор ru-стиля, не переводчик (как и предупреждал research/06). Разрыв с API держится у всех. **Побочная находка для бэкенда:** thinking у Qwen3.5+/GLM/Gemini для роли переводчика обязателен к отключению (иначе пустой/обрезанный вывод — ловилось дважды: эксп. 02 и здесь).
### Следующие шаги полигона
- [x] Прогон refusal-бенчмарка (SFW/L1/L2-violence часть) — 66/66 ok, пороги в 02; explicit-часть ждёт фрагментов владельца.
- [x] Эталон DeepSeek для сравнения черновиков — в 03.
- [ ] llama.cpp `--n-cpu-moe` для 30b-a3b — **сборка готова** (CUDA 12.6 user-space, пути/флаги в 03), замер после рестарта WSL с memory=26GB. Пул моделей для сравнения поколений/абляции/ru-адаптации докачан: qwen3:8b, qwen3.5:9b (+abliterated), ruadapt-qwen3:8b.
- [x] llama.cpp `--n-cpu-moe` для 30b-a3b — перемерено (потолок 13.5 tok/s, см. выше).
- [ ] Прогон Qwen Model Studio (`data_inspection_failed`) и OpenRouter — при появлении ключей.
- [ ] Проверка ru-агрегаторов: проброс cache-hit DeepSeek (два одинаковых запроса, сравнить usage) — разблокирует экономику ru-контура.
- [ ] Довалидация калибровки на 35 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest).