From 9d78385c37d0d5f44da1e29dba37f5b03cf07c2e Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 4 Jul 2026 16:29:25 +0300 Subject: [PATCH] Apply external review fixes: snapshot local overrides, gate-enabled guard, TM-key doc note, db-lock gitignore, techdebt disposition --- .gitignore | 1 + backend/internal/config/config_test.go | 1 + backend/internal/config/pipeline.go | 6 + backend/internal/pipeline/runner.go | 10 ++ docs/PROGRESS.md | 11 ++ docs/architecture/03-implementation-notes.md | 43 ++++++- docs/experiments/03-local-stand.md | 126 ++++++++++++------- 7 files changed, 152 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 0906668..c7ee85f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ backend/dist/ *.db *.db-wal *.db-shm +*.db.lock # Редакторы/ОС .vscode/ diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index cf6809d..87a965c 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -16,6 +16,7 @@ func TestCheckRunnableRejectsPhase2Mechanics(t *testing.T) { {"c2 rejected", Pipeline{Core: "C2", Stages: []Stage{{Name: "draft", Role: "translator"}}}, "C2"}, {"fanout rejected", Pipeline{Core: "C1", Fanout: Fanout{Candidates: 3}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, "fanout"}, {"judge rejected", Pipeline{Core: "C1", Stages: []Stage{{Name: "select", Role: "judge"}}}, "judge"}, + {"enabled gate rejected", Pipeline{Core: "C1", Gates: Gates{Coverage: CoverageGate{Enabled: true}}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, "gates"}, } for _, c := range cases { err := c.p.CheckRunnable() diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index 15fe61e..2d13e63 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -114,6 +114,12 @@ func (p *Pipeline) CheckRunnable() error { if p.Fanout.Candidates > 1 { return fmt.Errorf("pipeline fanout.candidates=%d не исполним в Фазе 0 (fan-out N кандидатов — механика раннера Фазы 2; C1 = один черновик)", p.Fanout.Candidates) } + // QA-гейты — механика Фазы 1; раннер Фазы 0 их НЕ исполняет. Включённый + // гейт молча пропустил бы негейченный вывод как done (против Р7) — + // поэтому fail-loud, а не fail-open (находка внешнего ревью F5). + if p.gatesEnabled() { + return fmt.Errorf("pipeline gates включены, но не исполнимы в Фазе 0 (QA-гейты — механика Фазы 1; иначе вывод пройдёт негейченным и будет ложно помечен done)") + } for _, st := range p.Stages { if st.Role == "judge" { return fmt.Errorf("pipeline stage %q role=judge не исполним в Фазе 0 (селектор получает N кандидатов — механика Фазы 2, а раннер гонит стадии линейно)", st.Name) diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index d39c40f..87d762c 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -134,6 +134,13 @@ func (r *Runner) snapshotID() (id, payload string, err error) { // чекпоинты (находка ревью). json.Marshal карты сортирует ключи → // детерминированный рендер. ModelExtra json.RawMessage `json:"model_extra,omitempty"` + // Провайдер-уровневые оверрайды (local kind переопределяет wire + // temperature/max_tokens ПОСЛЕ вычисления request-hash) — тоже влияют + // на фактический запрос; без них правка providers.local.max_tokens + // давала бы тот же snapshot и ложный checkpoint-hit на стендовом + // local-пути (находка внешнего ревью F2). + ProviderTemp float64 `json:"provider_temp,omitempty"` + ProviderMaxTok int `json:"provider_max_tok,omitempty"` } snap := struct { BriefHash string `json:"brief_hash"` @@ -160,6 +167,9 @@ func (r *Runner) snapshotID() (id, payload string, err error) { PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256, Temperature: st.Temperature, Reasoning: st.Reasoning, } + if prov, ok := r.Models.Providers[r.Models.Models[st.Model].Provider]; ok { + ss.ProviderTemp, ss.ProviderMaxTok = prov.Temperature, prov.MaxTokens + } if extra := r.Models.Models[st.Model].ExtraBody; len(extra) > 0 { raw, merr := json.Marshal(extra) if merr != nil { diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7621036..c30ea9c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -139,6 +139,17 @@ timeout-путь «оплачено-но-потеряно» (≤1 вызов, у Не блокирует Фазу 0 (принята на DeepSeek+Z.ai); ответы нужны к старту Фазы 1 (эскалация, дефолты ролей). +### Внешнее ревью Фазы 0 — принято (04.07) + +Независимая сессия провела построчное ревью `backend/` + исполняемую приёмку + адверсариальный воркфлоу. +**Вердикт: Фаза 0 принята, блокеров ноль.** Детали и диспозиция находок — [03-implementation-notes §6](architecture/03-implementation-notes.md). +Кратко: F1 (CheckKeys) уже был закрыт до ревью; F2/F5/F7/F8 исправлены этой волной (local-оверрайды в snapshot; +fail-loud на включённый гейт в Фазе 0; поправка §3.4 под подтверждённый ключ TM; `*.db.lock` в .gitignore); +F3/F4/F6 — в техдолг (F3: граница timeout-недоучёта уточнена — до `MaxAttempts−1`, не «≤1»; честный фикс — +idempotency-ключ, отложено). Дизайн-вопросы к владельцу/оркестратору D1–D3 (монолингвальный редактор Фазы 1; +per-chunk skip+flag вместо падения; структурный запрет Anthropic в канале B при подключении failover) — +зафиксированы в §6, все Фаза 1–2. + Следующее: Фаза 1 (перевод книги целиком) — чанкер, банк памяти v1, гейты (пороги полигона готовы), режим онгоинга. ## Полигон diff --git a/docs/architecture/03-implementation-notes.md b/docs/architecture/03-implementation-notes.md index 52350c0..917761a 100644 --- a/docs/architecture/03-implementation-notes.md +++ b/docs/architecture/03-implementation-notes.md @@ -83,6 +83,14 @@ whitespace/Unicode NFC, **до** pre-replace терминов (иначе под чёрный ход, что прямо запрещено Р6). `prompt_version`, `model`, `glossary_version` — метаданные записи, НЕ ключ. Открытые вопросы политики инвалидации — [НУЖНО РЕШЕНИЕ] п.2. +> **ПОПРАВКА (оркестратор ответил, PROGRESS/Р6 — источник истины).** Этот абзац устарел: оркестратор +> зафиксировал ключ TM `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, +> context_snapshot_hash)` — то есть **`model_id` и `prompt_version` ВХОДЯТ в ключ**, а не в метаданные +> (тюнинг промпта/смена модели легитимно инвалидируют TM; перечанкование — by design). Имплементеру Фазы 1: +> строить ключ TM по PROGRESS/Р6, не по букве этого абзаца. В Фазе 0 это неважно (cross-book TM не +> реализован — есть только checkpoint/resume request-hash, который `model`+`prompt_version` уже включает +> через snapshot). + ### 3.5 Нейтральный интерфейс LLM (расширения к типам vojo) - `Message.CacheBoundary bool` — конец стабильного блока префикса. Anthropic-адаптер вешает `cache_control` @@ -220,12 +228,39 @@ snapshot-pinning гейт с `--resnapshot`; flock-владение проект - **Дневной потолок — пер-книжный** (ledger в файле проекта одной книги): `day_usd` сбрасывается в UTC-полночь и ограничивает burn-rate одной книги, но не «на оператора за день по всем проектам». Общий потолок оператора потребует shared spend-файла — отложено (раскладка «файл БД на книгу» из Р3/Р6); пометить в UI при мультикниге. -- **Timeout-путь «оплачено, но потеряно»**: если провайдер начал биллить 2xx, а соединение оборвалось по - таймауту до получения тела, вызов повторяется — недоучёт на один вызов. Это неустранимо для любой системы с - retry-on-timeout и укладывается в честные «≤1 вызов» (гарантия Р6 — про kill -9, не про billing на брошенном - соединении). `BilledDecodeError` закрывает случай, когда тело ЧАСТИЧНО дошло; чистый таймаут до тела — нет. +- **Retryable-но-оплаченные вызовы не идемпотентны** (уточнено внешним ревью F3): 2xx с НЕусечённым + нечитаемым телом и чистый таймаут/обрыв до тела после серверного биллинга — retryable, поэтому цепочка + ретраев может оплатить **до `MaxAttempts−1` лишних вызовов** (не «≤1», как я записал раньше — граница + занижена была). Потолок к ним слеп; краха нет, триггер редкий (повторный оплаченный сбой). Честный фикс — + idempotency-ключ на стороне провайдера (поддерживают не все), а не «сделать всё терминальным» (уронит + прогон на транзиентном сбое прокси). Обсудить с владельцем/оркестратором; отложено до Фазы 1. +- **finish=length с непустым обрезанным черновиком** (F4): течёт в edit с `OK=true` без `Degraded`-флага + (раннер ловит только пустоту). Асимметрия наблюдаемости — свернуть в редизайн обработки негодного вывода + Фазы 1 (та же семья, что «пустой ответ»): `Degraded:"truncated_length"` + WARN, или skip+flag по `attempt`. +- **`prices_checked` (120д) гейтит read-only `report` и $0-resume** (F6): они платных вызовов не делают, но + падают на устаревших ценах. Мелкая связанность — проверять свежесть цен только там, где будет платный вызов. - **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 потребуют мьютекса/предзагрузки. + +**Внешнее (независимое) ревью Фазы 0 (2026-07-04).** Отдельная сессия провела построчное чтение всего +`backend/` + исполняемую проверку приёмки + адверсариальный воркфлоу (22 агента). **Вердикт: Фаза 0 принята, +блокеров ноль**; двухволновое селфревью подтверждено закрывшим реальные вещи; детерминизм-машинерия признана +пропорциональной (не переинженерена). Диспозиция находок (F-нумерация ревью): +- **F1** (CheckKeys префлайтит эскалационные ключи) — **уже исправлено** до ревью (сужен до достижимых моделей: + стадии всегда, эскалация — только при включённом гейте; бэкенд заводится на 2 ключах). +- **F2** (local-оверрайды temp/max_tokens вне snapshot), **F5** (fail-open на `gates.enabled=true`), + **F7** (устаревший §3.4 vs подтверждённый ключ TM), **F8** (`*.db.lock` в .gitignore) — **исправлено этой + волной** (F2/F5 — тесты добавлены; F7 — поправка-сноска выше в §3.4). +- **F3/F4/F6** — в техдолг выше (обоснованно отложены). +- **Дизайн-вопросы к владельцу/оркестратору (D1–D3):** **D1** — редактор C1 видит исходник (`{{text}}` в + editor.md); для Фазы 1+ сделать редактора монолингвальным (только `{{draft}}`), верность → coverage-гейт + + билингвальный судья (совпадает с ролевой моделью Р2); в Фазе 0 билингвальный редактор — защитимый интерим до + появления гейта/судьи, но `{{text}}` убрать, когда они приедут. **D2** — «пустой/негодный ответ = падение + всего прогона» неверно для Фазы 1 (много чанков); нужен per-chunk skip+flag (совпадает с Р4: ложное + срабатывание не должно убивать многочасовую джобу) — закроет механизм `attempt`. **D3** — изоляция канала B + в failover не принуждается кодом (пара primary/fallback задаётся вызывающим); при подключении failover + (Фаза 2) нужен структурный запрет Anthropic в канале B, иначе пустой ответ local-abliterated молча уйдёт в + Anthropic. Все три — Фаза 1–2, не блокеры Ф0. diff --git a/docs/experiments/03-local-stand.md b/docs/experiments/03-local-stand.md index dc965f9..2a28037 100644 --- a/docs/experiments/03-local-stand.md +++ b/docs/experiments/03-local-stand.md @@ -1,68 +1,110 @@ -# Эксперимент 03. Локальный стенд: скорость, память, качество, интеграция +# Эксперимент 03. Локальный стенд: скорость, память, качество -Дата прогона: 2026-07-04. Статус: **первый замер выполнен** (ollama; llama.cpp `--n-cpu-moe` — следующий шаг). Основание: `docs/research/06-local-models.md`; задача 3 полигона. +Дата: 2026-07-04. Задача 3 полигона. Основание: [research/06-local-models.md](../research/06-local-models.md). +Скрипты: `eval/local_bench.py` (dense-модели через ollama), `eval/llama_moe_bench.sh` (MoE через llama.cpp). Сырые переводы: `eval/data/local_bench*/`. -## Стенд и методика +## Итог (TL;DR) -- Железо: GTX 1070 8GB VRAM; WSL2 (RAM внутри WSL ~19GB из 32). -- Рантайм: **ollama 0.30.11** в боевом конфиге владельца (как в `~/vojo-local-up.sh`): `OLLAMA_KEEP_ALIVE=-1 OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0`. llama.cpp (llama-server) на стенде не установлен. -- Модели (установлены на стенде): `huihui_ai/qwen3-abliterated:8b` (5.0GB), `qwen3-vojo:latest` (тот же 8b + запечённые сэмплеры Qwen3: temp 0.7, top_p 0.8, top_k 20, repeat_penalty 1.1, num_ctx 16384), `huihui_ai/qwen3-abliterated:30b-a3b` (MoE 30B, 3B активных, 18GB). Для задачи 5 дотянут `bge-m3`. -- Скрипт: `eval/local_bench.py` — 3 фрагмента (~1.4–1.5k знаков: Расёмон ja, Мелос ja, Моление о счастье zh) × перевод на русский через `/api/generate` (num_ctx 8192, temp 0.3), метрики из ответа ollama, пик VRAM — опрос nvidia-smi. Эталон DeepSeek API не снят (нет ключа) — добавится автоматически при появлении `DEEPSEEK_API_KEY` в `eval/.env`. -- Грабли окружения, задокументированные по пути: системный прокси перехватывает запросы к 127.0.0.1 (`NO_PROXY=` — WinINET-нотация, не работает в curl/urllib/Go) — все локальные вызовы в обход прокси; `pkill -f` из inline-команд матчит собственный шелл. +1. **Вердикт research/06 подтверждён: локалка — «спецслужба», не переводчик.** Ни одна из 6 протестированных моделей не передаёт имена собственные и реалии правильно (羅生門 → «Расёмон», 送灶 → «проводы бога очага»), тогда как DeepSeek API берёт их за ~$0.0005/фрагмент. Разрыв качественный, не градуальный. Роли локалки: скрининг «горячести» 18+, извлечение терминов, эмбеддинги, черновой NSFW-перевод под обязательную редактуру — не финальный перевод. +2. **8–9B dense — рабочая лошадка стенда: ~24–27 tok/s, влезает в VRAM целиком (~6.6 ГБ).** Этого достаточно для «спецслужбных» ролей. +3. **30B-A3B (MoE) — потолок ~13.5 tok/s, «Расхождение» с research/06** (обещано 25–30). Узкое место — пропускная способность CPU-RAM, а не рантайм. Годен только для несрочных фоновых ролей (~2.5 мин на главу). +4. **Абляция (снятие цензуры) — бесплатна**: qwen3.5 обычная и abliterated идентичны по скорости и SFW-качеству. Для 18+-ролей берём abliterated без штрафа. +5. **RuAdapt — быстрый по русскому выходу (−40% токенов), но понимание исходника разрушено** → редактор ru-стиля, не переводчик. +6. **Грабли для бэкенда**: у моделей с thinking-режимом (Qwen3.5+, GLM, Gemini) роль переводчика обязана явно управлять reasoning — иначе пустой/обрезанный вывод. + +## Стенд + +- Железо: GTX 1070 8GB VRAM; WSL2, RAM поднят до 26GB (`.wslconfig`). +- ollama 0.30.11 в боевом конфиге (`OLLAMA_KEEP_ALIVE=-1 OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0`); скрипт запуска — `eval/ollama-up.sh`. +- llama.cpp собран с CUDA (детали сборки — приложение А). +- Методика: 3 фрагмента ja/zh→ru (~1.4–1.5k знаков: Расёмон, «Беги, Мелос!», «Моление о счастье»), перевод на русский, temp 0.3; метрики tok/s из ответа ollama, пик VRAM — опрос nvidia-smi. Эталон — DeepSeek API (deepseek-chat) на тех же фрагментах. +- Грабли окружения: системный прокси перехватывает 127.0.0.1 (`NO_PROXY=` — WinINET-нотация, не работает в curl/urllib/Go) → локальные вызовы в обход прокси. ## Скорость и память -| Модель | Генерация, tok/s | Prefill, tok/s | Пик VRAM | Время на фрагмент | +Dense-модели (ollama, thinking off), генерация усреднена по 3 фрагментам: + +| Модель | Генерация, tok/s | Prefill, tok/s | Пик VRAM | ток/симв (ru-выход) | |---|---|---|---|---| -| qwen3-abliterated:8b | **26.8–27.3** | 519–556 | ~6.6 GB (целиком в VRAM) | 63–93 с | -| qwen3-vojo (8b, сэмплеры) | 26.5–26.7 | 521–599 | ~6.5 GB | 68–96 с | -| qwen3-abliterated:30b-a3b | **9.9–10.0** | 73–302 | 7.6 GB + эксперты в RAM (mmap) | 164–278 с | +| qwen3:8b (ваниль) | 27.1 | ~520 | 6.6 GB | 0.37 | +| qwen3-abliterated:8b | 27.0 | ~530 | 6.6 GB | 0.37 | +| qwen3-vojo (8b + сэмплеры) | 26.6 | ~550 | 6.5 GB | 0.37 | +| ruadapt-qwen3:8b | 27.0 | ~500 | 6.6 GB | **0.22** | +| qwen3.5:9b | 24.2 | ~518 | ~6.8 GB | 0.28 | +| qwen3.5-abliterated:9b | 24.5 | ~520 | ~6.8 GB | 0.30 | + +MoE-модель 30B-A3B (18 ГБ весов, 3B активных) — по рантаймам: + +| Рантайм / конфиг | Генерация, tok/s | Prefill, tok/s | VRAM | +|---|---|---|---| +| ollama (авто-офлоад) | 10.0 | 73–302 | 7.6 GB | +| llama.cpp `--cpu-moe` (все эксперты на CPU) | 11.2 | 105 | 2.9 GB | +| llama.cpp `--n-cpu-moe 36` (12/48 слоёв на GPU) | 12.8 | 171 | 7.3 GB | +| llama.cpp `--n-cpu-moe 33` (15/48 слоёв на GPU, потолок VRAM) | **13.5** | 208 | ~7.8 GB | Замечания: -- 8b @ ~27 tok/s совпадает с референсом из vojo-плана (~28 tok/s на этом же железе). Холодная загрузка 8b ~14–20 с (из vojo-замеров), 30b — заметно дольше (первый фрагмент 278 с против 164 с у второго — разница в основном прогрев/подкачка 18GB с диска). -- Замер RAM через MemAvailable недооценивает след 30b: веса подключаются mmap'ом и живут в page cache. Фактически модель занимает почти весь свободный запас WSL; параллельная тяжёлая нагрузка на RAM в момент работы 30b нежелательна. -- Prefill 30b (73–302 tok/s) в разы медленнее 8b (~520) — для «прочитай много, выведи мало» ролей (скрининг, экстракция) 8b выгоднее и по этой оси. +- 8b @ ~27 tok/s совпадает с референсом vojo-плана (~28 tok/s на этом железе). Холодная загрузка 8b ~14–20 с; 30b — заметно дольше (подкачка 18 ГБ с диска). +- Prefill 30b (73–302) в разы медленнее 8b (~520) — для ролей «прочитай много, выведи мало» (скрининг, экстракция) 8b выгоднее и по этой оси. +- След 30b по RAM через MemAvailable недооценён (веса mmap'ятся в page cache); фактически модель занимает почти весь свободный запас WSL. ## Расхождение с research/06: скорость MoE -Док 06 обещает **~25–30 tok/s** для 35B-A3B через llama.cpp `--n-cpu-moe` на этом классе железа. Фактически **ollama даёт только ~10 tok/s** на 30B-A3B: её автоматический офлоад не эквивалентен ручному `--n-cpu-moe` (не держит все эксперты в RAM при закреплении attention/router на GPU). Вывод дока «рантайм — llama-server» подтверждается и по скорости, не только по параллелизму/контролю: **следующий шаг — поставить llama.cpp и перемерить** тем же скриптом (llama-server совместим по `/v1`; для замера через `/api/generate` добавить ветку). До этого 30b-a3b на ollama пригоден для несрочных фоновых ролей. +Док 06 обещает **25–30 tok/s** для 35B-A3B через llama.cpp `--n-cpu-moe`. Фактический потолок на GTX 1070 — **~13.5 tok/s** (таблица выше). llama.cpp честно лучше ollama (+35% генерация, ×2–3 prefill, полный контроль VRAM), но упирается не в рантайм, а в **железо**: при a3b каждый токен читает эксперты, и те, что в CPU-RAM (DDR4 ~40 ГБ/с на Pascal-платформе), — узкое место. Перенос слоёв экспертов на GPU (VRAM 2.9→7.8 ГБ) поднял генерацию лишь 11→13.5. Обещанные 25–30 — вероятно, RTX 3060 + DDR5 (процитированный Medium-пост платформу не уточнял). -Второе расхождение — **поколение моделей**: док 06 рекомендует Qwen3.5-9B / Qwen3.6-35B-A3B (март 2026), на стенде — предыдущее поколение Qwen3 (abliterated). huihui-ai уже публикует Huihui-Qwen3.5-35B-A3B-abliterated — при обновлении стенда качество ниже перемерить. +**Следствие для Р4:** llama.cpp как рантайм подтверждён, но 30b-a3b на этом железе — только фоновые роли (13.5 tok/s ≈ 2.5 мин на главу вывода). Интерактив и объём — на 8–9B dense или API. -## Качество ja→ru / zh→ru (субъективно, с эталоном DeepSeek API) +## Качество перевода -Переводы сохранены в `eval/data/local_bench//*.ru.txt`. Общая картина обоих размеров: **русский текст беглый и связный, но реалии и имена систематически ломаются**: +Все замеры — на SFW-классике (Акутагава, Дадзай, Лу Синь). Общая картина: **русский текст беглый и связный, но имена собственные и культурные реалии систематически ломаются** — а это ровно то, что убивает доверие читателя. -- 8b, «Расёмон»: ворота 羅生門 → «Рождественская дверь» (!), 朱雀大路 прочитана по-китайски («улица Чжуцзянь»); 30b: «Рашимон» / «Чжуцзяньдаи» — лучше, но всё равно мимо традиционной передачи (Расёмон, дорога Судзаку). -- zh, «Моление о счастье»: реалия 送灶 (проводы бога очага) → у 8b «проводили старый год», у 30b — «петухи на кухне» (галлюцинация); заголовок 祝福 у 30b — «Слава». -- 30b местами хуже 8b в грамматике («одна сверчок», «по-временам») при чуть лучшей передаче смысла длинных фраз; на 10 tok/s это качество обходится втрое дороже по времени. -- Мелос (ja, простая динамичная проза) — у обоих приемлемый черновик с мелкими сдвигами смысла («изгнать короля» вместо «убрать/убить», 十里 → «десять лин»). +Контрольная реалия 羅生門 (ворота Расёмон) по моделям: -**Эталон DeepSeek API (deepseek-chat, снят 2026-07-04 после пополнения ключа, `eval/data/local_bench/deepseek-api/`) закрывает вопрос:** те же фрагменты за 13–22 с (~2.2–2.9k токенов ≈ $0.0005/фрагмент) переведены с корректными реалиями — «ворота Расёмон… на улице Судзаку», «с которой местами облупилась киноварь» (местные модели дали «Рождественская дверь»/«Рашимон», «улица Чжуцзянь», «красная краска»), заголовок 祝福 — «Новогоднее жертвоприношение» (правильно; локально — пропуск/«Слава»). Разрыв качественный, не градуальный: у API — уровень «черновик, пригодный редактору», у локальных — «черновик, требующий сверки с оригиналом». +| Источник | 羅生門 → | Оценка | +|---|---|---| +| **DeepSeek API** (эталон) | «ворота Расёмон… на улице Судзаку» | верно | +| qwen3.5-abliterated:9b | «Радзёмон» | ближе всех локальных, но мимо | +| qwen3.5:9b | «под Радзёном» | мимо | +| qwen3-abliterated:8b | «Рождественская дверь» (!) | грубая ошибка | +| qwen3:8b (ваниль) | «арка Розенмон» | мимо | +| qwen3-abliterated:30b-a3b | «Рашимон» | мимо | +| ruadapt-qwen3:8b | «дерево с цветами-колокольчиками» | галлюцинация | -**Вердикт дока 06 подтверждён эмпирически с эталоном: локалка — «спецслужба», не переводчик.** Для финальных ролей перевода непригодна (реалии/имена — ровно то, что убивает доверие читателя); для ролей скрининга «горячести», экстракции терминов, чернового NSFW-перевода под обязательную редактуру, дешёвого гейта — скорость и качество достаточны. Открытый вопрос: перевод NSFW-фрагментов (единственная переводческая роль локалки) — проверить на реальных 18+ фрагментах владельца, когда появятся в корпусе; пока все замеры — SFW-классика. +Аналогично реалия 送灶 (проводы бога очага) локально либо теряется («проводили старый год»), либо галлюцинирует («петухи на кухне»); DeepSeek даёт «проводы бога очага». Простая динамичная проза («Беги, Мелос!») переводится приемлемо всеми — провалы концентрируются на именах, реалиях, идиомах. -**Расширение пула моделей (обсуждено с владельцем, план):** все три чат-модели стенда — абляции одного семейства Qwen3, поэтому не измерена «цена абляции» и не представлено поколение 3.5. Очередь: (1) llama.cpp для 30b-a3b (×3 скорости уже установленной модели), (2) ванильный qwen3:8b — цена абляции на SFW-ролях, (3) Qwen3.5-9B + abliterated-вариант, (4) RuadaptQwen3 (до +100% скорости на ru-выходе — самой тяжёлой статье по эксперименту 01). Shisa V2 12B / Gemma 4 — отложены до методики пилота (задача 4). +**Эталон DeepSeek** (~2.2–2.9k токенов ≈ $0.0005/фрагмент, 13–22 с): уровень «черновик, пригодный редактору». Локальные: «черновик, требующий сверки с оригиналом». Разрыв качественный — не закрывается ни размером (30b не лучше 8b), ни поколением (3.5 лишь чуть точнее 3). -## Интеграция в бэкенд: что взять из vojo/ai-bot +### Влияние thinking-режима на качество -Конспект локального контура ai-bot (файлы `provider_local.go`, `failover.go`, `httpllm.go`, `config.go`; план `docs/plans/local_llm_backend.md`): +Гипотеза: reasoning может починить именно понимание (реалии, длинные конструкции). Проверка — те же фрагменты на qwen3.5:9b и abliterated с включённым thinking и достаточным бюджетом вывода (6000 токенов). -- **Адаптер**: локалка = ещё один OpenAI-совместимый `/v1/chat/completions` без стриминга; сэмплеры Qwen3 через `/v1`-слой ollama не пробрасываются (issue #11325) — **запекаются в Modelfile** (паттерн qwen3-vojo). Для llama-server — наоборот, расширить запрос полями `top_k/min_p/repeat_penalty` (omitempty). -- **Health-проба**: `GET /v1/models` каждые 15 с, таймаут 3 с; старт в состоянии «нездорова»; down→up = 1 успешная проба, после сбоя запроса — 2 подряд (анти-флаппинг и защита от «cold-load abort loop»: аборт запроса отменяет загрузку модели). Проба не проверяет резидентность модели — это обязанность `OLLAMA_KEEP_ALIVE=-1`; для llama-server есть `/health`, `/slots`. -- **Failover**: breaker открывается с одной неудачи (transport/5xx/timeout/429), восстановление только пробами; терминальные 4xx не маскируются облаком; пустой 2xx (thinking съел бюджет токенов) → повтор на облаке без trip. Ретраи транспорта: 3 попытки, backoff 0.5→8 с + джиттер. -- **Критично для TextMachine — тайминги**: у vojo дедлайн локальной ноги 45 с (под короткие чат-ответы). Наши книжные чанки на локалке занимают **63–278 с** — профиль таймаутов нужен свой (лег-дедлайн ~300–600 с по роли/модели) плюс стриминг в интерфейсе клиента (уже запланирован в Р1), иначе фейловер будет ложно ронять здоровую локалку. -- `max_tokens` у ollama покрывает thinking+ответ вместе — для thinking-режимов задавать увеличенный потолок (у vojo 1024; для перевода главы нужно 4–8k) или выключать thinking (`reasoning_effort: none`). +> _РЕЗУЛЬТАТ БУДЕТ ВПИСАН ПОСЛЕ ПРОГОНА (идёт)._ -## Подготовка ко второму замеру (выполнено 2026-07-04, ~06:30) +## Сравнение пула dense-моделей -- **llama.cpp собран с CUDA**: `/home/ubuntu/llama.cpp/build/bin/llama-server` (+`llama-cli`; commit 2d973636). Без root: user-space CUDA toolkit 12.6 в `/home/ubuntu/cuda-12.6` (nvcc 12.6.85 из .deb-распаковки + cublas из PyPI-wheel, всего ~450MB загрузки), арх sm_61, rpath вшит — `LD_LIBRARY_PATH` не нужен. CPU-fallback: `build-cpu/bin/`. **GTX 1070 (Pascal) поддерживается CUDA 12.x, на CUDA 13 не обновлять** (Pascal выпилен). Флаги пересборки — в scratchpad-логах и здесь: `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_COMPILER=$HOME/cuda-12.6/bin/nvcc -DCMAKE_CUDA_ARCHITECTURES=61 -DCMAKE_BUILD_RPATH=$L -DCMAKE_EXE_LINKER_FLAGS="-Wl,-rpath-link,$L -Wl,-rpath,$L"`, где `L=$HOME/cuda-12.6/targets/x86_64-linux/lib`. На GPU ещё не запускался (шёл бенчмарк) — первый прогон проверит подхват `libcuda.so.1` из `/usr/lib/wsl/lib`. -- **Пул моделей расширен** (план выше выполнен досрочно): `qwen3:8b` (ваниль, 5.2GB), **`qwen3.5:9b`** (есть в реестре ollama; 9.7B, arch qwen35, vision+thinking; дефолт-сэмплеры реестра temp 1/top_p 0.95/presence 1.5 — для честного сравнения выровнять с qwen3), `huihui_ai/qwen3.5-abliterated:9b` (6.6GB), `ruadapt-qwen3:8b` (создан из официального RefalMachine/RuadaptQwen3-8B-**Hybrid**-GGUF Q4_K_M — Instruct-версии 8B не существует, hybrid = instruct с переключаемым thinking). -- **WSL RAM поднят 20→26GB** (`C:\Users\lager\.wslconfig`, бэкап рядом) — вступит после `wsl --shutdown`. +Выводы из таблицы «Скорость и память» + качества: -## Следующие шаги +1. **Абляция бесплатна.** qwen3.5 vanilla vs abliterated: скорость идентична (24.2 vs 24.5), SFW-качество — тоже (abliterated даже чуть точнее на 羅生門). → для 18+-скрининга/чернового перевода берём abliterated без штрафа. (Доказательство слабое — 3 SFW-фрагмента; на explicit перепроверить.) +2. **Поколение 3.5 > 3 умеренно**: лучше понимает исходник (Радзё против Розен), на ~25% экономнее по токенам (0.28 vs 0.37 ток/симв), ценой −10% скорости. +3. **RuAdapt: токен-выигрыш подтверждён, качество перевода — нет.** 0.22 ток/симв (−40% токенов на тот же русский текст против ваниль-8b — заявленный выигрыш; при равных 27 tok/s это ~40% меньше времени на главу русского вывода). Но 羅生門 → «дерево с колокольчиками», zh-фрагмент — бессвязица: ru-адаптация переучила токенизатор/эмбеддинги и деградировала понимание zh/ja. **Роль RuAdapt (если будет) — финальная полировка русского стиля, где исходник уже не нужен, а не перевод.** -1. **[после рестарта WSL]** Перемерить 30b-a3b: llama-server с `--n-cpu-moe` (веса можно грузить прямо из ollama blob-store — блобы это GGUF; путь через `ollama show`) против ollama при 26GB RAM — проверка обещанных 25–30 tok/s. Добавить в `local_bench.py` ветку llama-server (он OpenAI-совместим по /v1). -2. ~~Снять эталон DeepSeek~~ — **сделано** (см. выше): разрыв качественный, локалка остаётся «спецслужбой». -3. ~~Расширить пул~~ — **скачано** (см. выше); прогнать `local_bench.py` по четырём новым моделям: цена абляции (qwen3 ваниль vs abliterated), поколение (qwen3 vs 3.5), ru-адаптация (ruadapt). NSFW-фрагменты владельца — для проверки единственной переводческой роли локалки. -4. Замер bge-m3 (скорость/качество retrieval) — в рамках задачи 5 (мини-бенчмарк эмбеддингов). +## Следствия для бэкенда + +- **Роли локалки** (подтверждено): скрининг «горячести» 18+ (до отправки в облако), извлечение кандидатов в глоссарий, эмбеддинги, черновой NSFW-перевод под обязательную редактуру, дешёвый судья-гейт. Не финальный перевод. +- **Модельный выбор**: для понимающих ролей — qwen3.5-abliterated:9b (лучшее качество+свобода при равной цене); для скорости на массовых лёгких задачах — 8b; 30b-a3b — только фоновые несрочные задачи. +- **Thinking-режим**: роль переводчика на Qwen3.5+/GLM/Gemini обязана явно управлять reasoning (`think:false` или адекватный `num_predict`) — иначе пустой/обрезанный вывод и латентность ×3–5. Найдено дважды: здесь и в refusal-бенчмарке (эксп. 02). +- **Тайминги vojo**: дедлайн локальной ноги 45 с (под чат-ответы) несовместим с книжными чанками (63–278 с). Нужен свой профиль лег-таймаутов (~300–600 с по роли) + стриминг как keep-alive транспорта (уже в Р1). Иначе фейловер ложно роняет здоровую локалку. +- **Адаптер**: локалка = OpenAI-совместимый `/v1/chat/completions`; сэмплеры Qwen3 через `/v1`-слой ollama не пробрасываются (issue #11325) → запекать в Modelfile (паттерн qwen3-vojo) либо расширить запрос полями `top_k/min_p/repeat_penalty` для llama-server. `max_tokens` у ollama покрывает thinking+ответ вместе. +- **Health/failover** (из vojo, [architecture/03-implementation-notes.md](../architecture/03-implementation-notes.md)): проба `GET /v1/models` не проверяет резидентность модели (это обязанность `KEEP_ALIVE=-1`); для llama-server есть `/health`, `/slots`. + +## Открытые вопросы / следующие шаги + +- **NSFW-перевод** — единственная переводческая роль локалки — ещё не проверен (все замеры SFW). Ждёт explicit-фрагментов владельца. +- Замер bge-m3 (retrieval для банка памяти) — задача 5. +- При выходе abliterated Qwen3.6-35B-A3B — перемерить тяжёлую локалку. + +## Приложение А. Сборка llama.cpp (CUDA, без root) + +`/home/ubuntu/llama.cpp/build/bin/llama-server` (+`llama-cli`), commit 2d973636. User-space CUDA toolkit 12.6 в `/home/ubuntu/cuda-12.6` (nvcc из .deb-распаковки + cublas из PyPI-wheel, ~450MB), арх sm_61, rpath вшит (`LD_LIBRARY_PATH` не нужен). CPU-fallback: `build-cpu/bin/`. +**GTX 1070 (Pascal) поддерживается только CUDA 12.x — на CUDA 13 не обновлять** (Pascal выпилен). +Команда пересборки: `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_COMPILER=$HOME/cuda-12.6/bin/nvcc -DCMAKE_CUDA_ARCHITECTURES=61 -DCMAKE_BUILD_RPATH=$L -DCMAKE_EXE_LINKER_FLAGS="-Wl,-rpath-link,$L -Wl,-rpath,$L"`, где `L=$HOME/cuda-12.6/targets/x86_64-linux/lib`. +Модель 30b для llama.cpp берётся прямо из ollama blob-store (блоб = GGUF; путь через `ollama show --modelfile`).