From 00f8e36fbd84adbf9a1f56495abb800b5bb71a39 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 11 Jul 2026 14:48:03 +0300 Subject: [PATCH] Land backend D15.2 stage A: bilingual editor with source injection, reflow, output-sanitizer gate, golden re-capture with floor/union/sanitizer pins, spec v3.1 design --- backend/configs/models.yaml | 28 +- backend/configs/pipeline-c1.yaml | 36 +- backend/configs/pipeline-c2.yaml | 17 +- .../D15.2-content-addressed-resume-spec.md | 85 +++- backend/internal/config/echo_mine_test.go | 40 +- backend/internal/config/pipeline.go | 20 +- backend/internal/pipeline/chunkrun.go | 21 +- backend/internal/pipeline/disposition.go | 9 + backend/internal/pipeline/escalation.go | 12 +- backend/internal/pipeline/golden_test.go | 49 ++- backend/internal/pipeline/mempostcheck.go | 3 +- .../internal/pipeline/runner_memory_test.go | 26 +- backend/internal/pipeline/sanitizer.go | 402 ++++++++++++++++++ backend/internal/pipeline/sanitizer_test.go | 205 +++++++++ backend/internal/pipeline/snapshot.go | 28 +- backend/internal/pipeline/stagerun.go | 14 +- backend/internal/pipeline/status.go | 14 +- .../pipeline/testdata/golden/capture.golden | 230 ++++++---- .../testdata/golden/glossary-seed.yaml | 13 +- .../pipeline/testdata/golden/pipeline.yaml | 4 +- .../pipeline/testdata/golden/source.txt | 8 + backend/prompts/editor-mono.md | 16 + backend/prompts/editor.md | 17 +- backend/prompts/translator.md | 4 +- 24 files changed, 1101 insertions(+), 200 deletions(-) create mode 100644 backend/internal/pipeline/sanitizer.go create mode 100644 backend/internal/pipeline/sanitizer_test.go create mode 100644 backend/prompts/editor-mono.md diff --git a/backend/configs/models.yaml b/backend/configs/models.yaml index d99e8aa..8ae9196 100644 --- a/backend/configs/models.yaml +++ b/backend/configs/models.yaml @@ -55,10 +55,11 @@ providers: reasoning: subset timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 } - xai: # Grok — центральная модель Фазы 1: дефолт-редактор SFW + главный тир канала B + судья 18+. - # ⚠️ ГИГИЕНА АККАУНТОВ (D8/оркестратор): XAI_API_KEY обязан быть ЧИСТЫМ прод-аккаунтом - # БЕЗ data-sharing. Промо-$150-аккаунт (data-sharing) — ТОЛЬКО eval/NSFW, реальные книги - # через него гнать нельзя. По ключу это не проверяется — подтвердить перед боевым прогоном. + xai: # Grok — главный тир канала B (18+) + судья 18+; из редакторских ролей СНЯТ (D30.1 — reasoning-off no-op). + # ⚠️ КЛЮЧЕВАЯ ПОЛИТИКА (D27, супрсидит «чистый ключ» D19.4/D20.3): ЕДИНЫЙ XAI_API_KEY С data-sharing + # (промо) идёт на ВСЕ тесты И пилот И прод; data-sharing ОТКЛЮЧАЕТСЯ перед релизом (сверить механику + # с вендор-докой — чек-лист первого боевого прогона). Риски приняты владельцем (копирайт 蛊真人 в обучение — + # только свои тесты, для клиентских книг НИКОГДА; NSFW-история аккаунта). По ключу не проверяется. kind: openai base_url: https://api.x.ai/v1 api_key_env: XAI_API_KEY @@ -73,9 +74,10 @@ providers: # Gemini Фазы 2 (см. provider_anthropic.go). Эскалация — не-Anthropic (Kimi/GLM). gemini: # Google Gemini через OpenAI-совместимый слой (base .../v1beta/openai). Апекс канала A + - # судья Фазы 2. ⚠️ Нативный Gemini API нужен для safety-off судьи 18+ (safety_settings не - # проходят через OpenAI-слой — exp00); OpenAI-слой достаточен для перевода/эскалации (фильтры OFF - # по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9). + # судья violence/SFW Ф2 + премиум-эскалация редактора ЗА санитайзером (D30.1/30.3 — течёт преамбулой 6/6). + # ⚠️ Как судья 18+ ЭРОТИКИ НЕПРИГОДЕН (D22.6/exp11: 8× PROHIBITED_CONTENT, фильтр неконфигурируем — native + # НЕ спасает; первичный судья эротики = Grok). На violence/SFW Gemini-судья остаётся. OpenAI-слой достаточен + # для перевода/эскалации (фильтры OFF по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9). kind: openai base_url: https://generativelanguage.googleapis.com/v1beta/openai api_key_env: GEMINI_API_KEY @@ -187,9 +189,10 @@ models: temperature: { mode: send } reasoning: { control: effort, off_effort: none } note: >- - Дефолт-редактор SFW (D3, bake-off эксп-04) + главный пермиссив-тир канала B + судья 18+. - reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3). Включена - в edit-стадию C1/C2 (D1/D3, консолидация 05.07); прод-прогон — только с ЧИСТЫМ xAI-ключом без data-sharing. + Главный пермиссив-тир канала B + судья 18+ ЭРОТИКИ (первичный для НЕ-grok переводов — D22.6: Gemini + fail-closed на графичной эротике, native не спасает). Из редакторов СНЯТ (D30.1 — reasoning-off no-op; + editor теперь glm-5-билингв). reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3). + Ключ — единый с data-sharing, off перед продом (D27). gemini-3.1-pro-preview: # ⚠️ слаг ИМЕННО -preview: gemini-3.1-pro → HTTP 404 NOT_FOUND (live 2026-07-10) provider: gemini @@ -204,8 +207,9 @@ models: # Раньше «дать max_tokens ≥8000» — комментарий; теперь схема (thinking ⊆ бюджета, additive_total). min_max_tokens: 8000 note: >- - Апекс канала A / судья Фазы 2 (D3). min_max_tokens 8000 держит thinking внутри бюджета (резерв не слепнет; settle - биллит thinking через additive_total). Судья 18+ требует НАТИВНОГО Gemini (safety-off) — Ф2. Живьём 2026-07-10: chat 200. + Апекс канала A / судья violence/SFW Ф2 / премиум-эскалация редактора за санитайзером (D30.1). min_max_tokens 8000 + держит thinking внутри бюджета (резерв не слепнет; settle биллит thinking через additive_total). Как судья ЭРОТИКИ + непригоден (D22.6: PROHIBITED_CONTENT неконфигурируем, native не спасает — первичный судья эротики Grok). Живьём 2026-07-10: chat 200. mistral-large-2512: provider: mistral diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml index b062193..3de562a 100644 --- a/backend/configs/pipeline-c1.yaml +++ b/backend/configs/pipeline-c1.yaml @@ -34,9 +34,12 @@ retries: stages: - name: draft role: translator - model: deepseek-v4-flash + model: deepseek-v4-flash # интерим — переводчик по exp13 (draft качеством не мерен, D30.6); флип при развязке бейк-оффа prompt: ../prompts/translator.md - prompt_version: v0-draft + # D30.2 reflow: из translator.md убрано «Сохраняй разбивку на абзацы» (построчная + # вёрстка исходника деструктивна для ru — корень нечитаемости exp12 §2.5-S). Bump + # v0-draft→v1-reflow: сменил SHA промпта, осознанный --resnapshot. + prompt_version: v1-reflow temperature: 0.3 reasoning: "off" # single-hop echo-эскалация (D18: deepseek-эхо 25% на 蛊真人 → фолбэк выстрелит). deepseek-v4-pro — @@ -45,15 +48,18 @@ stages: escalate_to: deepseek-v4-pro - name: edit role: editor - # Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером). - # reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none, - # models.yaml) — омиссия у grok = low. Прод: только чистый xAI-ключ без data-sharing. - model: grok-4.3 + # Дефолт-редактор — glm-5 БИЛИНГВ (флип D1→D30.1, supersede D17.в): моно-редактор + # пассивен и структурно слеп к искажениям черновика (exp12 §2.2/2.5); билингв видит + # исходник (editor.md получил {{text}}). glm-5 — кандидат value по exp12 §3 (судейский #1, + # верность 5.0, 0 утечек преамбул; grok reasoning-off из редакторских ролей СНЯТ — no-op). + # gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6). + model: glm-5 prompt: ../prompts/editor.md - # D1-уточнение №3: коммит 6a68dfd сделал editor.md монолингвальным (убрал {{text}}) → - # сменил SHA, но лейбл остался v0-draft (один лейбл на два SHA). Бамп на v1-monolingual; - # осознанно меняет снапшот (боевых прогонов ещё не было). - prompt_version: v1-monolingual + # Bump v1-monolingual→v2-bilingual-reflow: editor.md стал билингвальным (D30.1) + reflow + # (D30.2, убрана «Сохраняй разбивку на абзацы»). Оба меняют SHA промпта → осознанный + # --resnapshot. Моно-вариант сохранён отдельно (prompts/editor-mono.md) — подтверждающий + # пилот-арм D13.1. + prompt_version: v2-bilingual-reflow temperature: 0.4 reasoning: "off" @@ -68,6 +74,16 @@ gates: en-ru: [0.70, 1.4] sent_cov_min: 0.75 min_chunk_chars: 500 # калибровка порогов — на фрагментах 500–2500 симв. + sanitizer: + # Output-санитайзер (D30.3): детерминированный вердикт-гейт «мгновенной + # нечитаемости» на ФИНАЛЬНОМ выходе — ведущие служебные преамбулы («Вот + # отредактированный перевод…»), хвостовые блоки заметок/правок, markdown-###, + # латиница-врезки в ru-выходе (порог), битые словоформы («Первок предок»). Ни + # один существующий гейт этот класс не ловит (exp12/флагманы §5). Дисциплина D2 + # (flag+skip): дефект → чанк flagged, мусор не течёт в TM/экспорт. precision>recall. + # ВКЛЮЧЁН для пере-прогона 25 глав (gemini течёт преамбулой 6/6 — премиум-редактор + # за санитайзером). Версия — вердикт-ось (переедет в verdictSnapshotID с D15.2). + enabled: true escalation: chains: diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml index fe9702d..a605491 100644 --- a/backend/configs/pipeline-c2.yaml +++ b/backend/configs/pipeline-c2.yaml @@ -24,7 +24,7 @@ stages: role: translator model: deepseek-v4-flash prompt: ../prompts/translator.md - prompt_version: v0-draft + prompt_version: v1-reflow # D30.2 reflow (тот же translator.md, что в C1) — label-SHA дисциплина temperature: 0.8 # C2: кандидаты сэмплируются горячее (Р2: T=0.6–1.0) reasoning: "off" # Judge-селектор (пара судей, шкала Комиссарова) — Фаза 2; стадия объявлена, @@ -40,15 +40,14 @@ stages: reasoning: "off" - name: edit role: editor - # Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером). - # reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none). - # (Стадия select выше — judge/glm-5, Gemini-слот Фазы 2; её НЕ трогаем.) - model: grok-4.3 + # C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют + # за C1: editor.md стал БИЛИНГВ (D30.1) + reflow (D30.2) — тот же общий файл. Дефолт-редактор + # по D30.1 — glm-5 билингв (grok reasoning-off из редакторов СНЯТ — no-op). (Стадию select + # выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.) + model: glm-5 prompt: ../prompts/editor.md - # D1-уточнение №3: editor.md стал монолингвальным (6a68dfd убрал {{text}}) → SHA сменился, - # лейбл v0-draft остался (один лейбл на два SHA). Бамп на v1-monolingual (тот же editor.md, - # что в C1). Стадию select выше НЕ трогаем. - prompt_version: v1-monolingual + # Бамп v1-monolingual→v2-bilingual-reflow (label-SHA дисциплина: тот же editor.md, что в C1). + prompt_version: v2-bilingual-reflow temperature: 0.4 reasoning: "off" diff --git a/backend/docs/D15.2-content-addressed-resume-spec.md b/backend/docs/D15.2-content-addressed-resume-spec.md index 4b79deb..2f164e3 100644 --- a/backend/docs/D15.2-content-addressed-resume-spec.md +++ b/backend/docs/D15.2-content-addressed-resume-spec.md @@ -1,10 +1,44 @@ -# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) +# D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) -**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до -ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в -`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в +**Статус:** **v3.1 — реализация РАЗБЛОКИРОВАНА (D30.9, промт `BACKEND_D152_SESSION_PROMPT.md`, +этап Б).** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные +пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/` +(зона бэкенда); при лендинге оркестратор сворачивает её в `docs/architecture/03-implementation-notes.md` новым разделом. +## 0-бис. v3.1-амендменты (D22.2 два обязательных + пре-имплементационные) + +Ревью D22.2 сконструировало и ИСПОЛНИЛО stale-hit, который v3 пропускал. Два обязательных +амендмента до реализации: + +- **(v3.1-a) `Book.SourceLang`/`Book.TargetLang` → `verdictSnapshotID`.** Языки — ВЕРДИКТ-входы, + потребляемые ВНЕ рендера: `classify` использует `TargetLang` для cjk-echo-проверки + (`disposition.go:196` `isCJKTarget`), `coverageCheck` использует пару `SourceLang`/`TargetLang` + для коридора `len_ratio` (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` (после + флипа D30.1 билингв-редактор рендерит `{{source_lang}}`, но не target) → на editor-стадии + `content_hash` их НЕ ловит, а вердикт (cjk/coverage) от них зависит. Сегодня они в ключе через + `brief_hash` (book-поле), но v3 дропает `brief_hash` → `content_hash`; значит нужен ЯВНЫЙ + вердикт-фейт. **Правило §4 переформулировано: судьбу обязано получить ЛЮБОЕ brief-поле, + потребляемое ВНЕ рендера** (языки — в `verdictSnapshotID`; инцидентально они и в translator + `content_hash` через рендер, но вердикт-зависимость — несущая). §4-строка добавлена. +- **(v3.1-b) `cache_ttl`-расщепление: `Context.CacheTTL` — advisory (НЕ в `wireSnapshotID`); + `prov.CacheTTL` — в `wireSnapshotID`.** Проверено кодом: `clients.go:38` передаёт клиенту + `CacheTTL: prov.CacheTTL` (провайдер-уровень, models.yaml, Anthropic-kind cache_control) — + **`Context.CacheTTL` (pipeline `context.cache_ttl`, «5m») НЕ доходит НИ ДО ОДНОГО клиента = + wire-ИНЕРТЕН.** v3 клал в `wireSnapshotID` инертный `Context.CacheTTL` и терял wire-влияющий + `prov.CacheTTL` — задом наперёд. v3.1: `Context.CacheTTL` демотирован до advisory (response- + нейтрален, reuse безопасен; НЕ пиним); `prov.CacheTTL` (пер-стадийно через резолвнутый провайдер) + → `wireSnapshotID` + `guard_hash` (меняет cache_control на Anthropic-проводе). Anthropic снят со + стека (models.yaml) → экспозиция нулевая, но механизм чист. §4-строки поправлены. + +**Пре-имплементационные (D22.2):** (1) axis-sensitivity тест `RequestHash` — флип `role`/`stage` +меняет хеш (сейчас мутация «удалить role» пережила бы сьют) → §12.9 тест-лист (`г-тер`); +(2) абзац «паритетные не-цели» §8 (classify-Source для editor-строк; `Retries`/`Escal.BudgetUSD` +вне хешей; транспорт-ретраи вне ключа); (3) line-refs освежены — **⚠ рефактор D23 расщепил +`runner.go` на `stagerun.go`/`chunkrun.go`/`escalation.go`/`bookrun.go`/`resume.go`/`status.go`; +исторические `runner.go:NNN`-ссылки ниже ИНДИКАТИВНЫ (по классу кода), актуальные локации — +в §12-плане и по grep имени функции.** + **Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить `snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1** @@ -146,7 +180,7 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir | Поле (сегодня) | Класс | Куда в v2 | Почему | |---|---|---|---| -| `brief_hash` | wire | **dropped** → `content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Caveat закрыт v3:** НЕрендеримые брифа-поля не покрываются `content_hash` — их судьбы назначены явно: `YoPolicy`/`StyleAllowlist` → `verdictSnapshotID` (строки выше, кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. | +| `brief_hash` | wire | **dropped** → `content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Правило (v3.1-a): ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера, обязано получить явную судьбу** (`content_hash` его не ловит). Назначено: `SourceLang`/`TargetLang` → `verdictSnapshotID` (cjk-gate/coverage-коридор — строка ниже, v3.1-a); `YoPolicy`/`StyleAllowlist` → `verdictSnapshotID` (кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. | | `chunker_version` | wire | **dropped** → `content_hash` | Правила чанкера дают `ch.Text` → `msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. | | `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). | | `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. | @@ -156,7 +190,9 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir | `context_assembly.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). | | `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. | | `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `msgs`. | -| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash` (в `msgs` только `Role`+`Content`; `msgsContentHash` опускает `CacheBoundary`/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на `content_hash` не рушится (см. примечание ниже таблицы). | +| `context_assembly.cache_ttl` (`Context.CacheTTL`) | advisory | **dropped (v3.1-b)** | ⚠ **Правка v3.1-b: wire-ИНЕРТЕН** — `clients.go:38` передаёт клиенту `prov.CacheTTL`, а `Context.CacheTTL` (pipeline `context.cache_ttl`) НЕ доходит НИ ДО ОДНОГО клиента. Response-нейтрален, reuse безопасен → НЕ пиним (v3 клал его в `wireSnapshotID` ошибочно). | +| `prov.CacheTTL` (провайдер, Anthropic cache_control) | wire (cache-control) | **wireSnapshotID** + `guard_hash` (v3.1-b) | Провайдер-уровневый TTL РЕАЛЬНО едет на провод (`clients.go:38`→`provider_anthropic.go` cache_control). Пер-стадийно через резолвнутый провайдер стадии. Anthropic снят со стека → экспозиция нулевая, механизм чист. | +| `Book.SourceLang` / `Book.TargetLang` | verdict | **verdictSnapshotID (v3.1-a)** | ⚠ **Правка v3.1-a.** Языки — ВЕРДИКТ-входы вне рендера: `TargetLang`→cjk-echo (`disposition.go:196`), пара→`len_ratio`-коридор (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` → `content_hash` editor-стадии их не ловит, а вердикт от них зависит. Инцидентально они и в translator `content_hash` (рендер `{{source_lang}}`/`{{target_lang}}`), но несущая — вердикт-зависимость. Уходят из ключа вместе с `brief_hash`; вердикт-фейт назначен явно. | | `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. | | `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). | | `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). | @@ -399,6 +435,20 @@ Reuse чекпоинта легален ⟺ **полная wire-идентичн расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`. +**Паритетные НЕ-цели (v3.1, D22.2 — что осознанно ВНЕ хешей).** Формула суперсет-паритета не обязана +хешировать входы, которые ни wire, ни вердикт: (а) **classify-Source для editor-строк** — `classify` +принимает `Source` для soft-refusal-порога и cjk-echo, но на editor-выходе источник — это черновик, а +не оригинал; это часть детерминированной классификации над `content_hash`-материалом, не отдельная +ось ключа. (б) **`Retries.RegenerateBeforeEscalate` и `Escalation.BudgetUSD`** — НЕ в снапшоте/ключе: +они управляют СКОЛЬКО попыток/хопов делать, а не байтами какого-либо вызова; их правка меняет, какие +`attempt`-оси материализуются, но каждый материализованный вызов уже уникально кейится своим `attempt` +(ретрай) или моделью эскалации (хоп) — уже покрыто. Смена `RegenerateBeforeEscalate` не пере-оплачивает +готовые чанки (их `attempt=0`-чекпоинты попадают), только разрешает/запрещает БУДУЩИЕ ретраи флагнутых. +(в) **Транспортные ретраи** (`timeouts` в models.yaml, backoff) — вне ключа by design (тот же +`RequestHash` через транспорт-ретраи и resume — F3-идемпотентность); их правка не инвалидирует ничего. +Эти три — паритетные не-цели: их отсутствие в `wireSnapshotID`/`guard_hash`/`verdictSnapshotID` +КОРРЕКТНО, а не пробел. + ## 9. Судьба `chunk_status.snapshot_id` и status-ридеров - **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку; @@ -510,16 +560,19 @@ per chunk×stage, а не рассуждает аналитически):** `ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3). -9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**) - → $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + - editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/ - capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать - temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке - (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под - coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) - editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не - только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е) - миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`. +9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**/ + **sanitizer**/**source_lang·target_lang** — v3.1-a) → $0 re-classify, вердикт обновлён; (б) append-термина + → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/ + модель/**temperature**/**reasoning**/capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: + ослабить `guard_hash` (убрать temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на + wire-нейтральном чанке (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — + re-classify под coverage (§5-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет + `RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д) + dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а + → проекция считает re-bill ОБЕИХ стадий чанка, не только translator (§7.1-бис; мутация «убрать правило + каскада» роняет проекцию до ~½);** (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`; + **(ж, v3.1-b) `Context.CacheTTL` вне `wireSnapshotID` (правка «5m»→«1h» не re-bill-ит); `prov.CacheTTL` + в `wireSnapshotID` (правка re-bill-ит Anthropic-стадию).** ## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3) diff --git a/backend/internal/config/echo_mine_test.go b/backend/internal/config/echo_mine_test.go index 6c682e0..a4b3f70 100644 --- a/backend/internal/config/echo_mine_test.go +++ b/backend/internal/config/echo_mine_test.go @@ -165,45 +165,51 @@ func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) { } } -// TestBoevoyConfigGrokEditorExplicitNone pins the consolidation fix (tasks 1-3): grok-4.3 -// resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT -// off-by-omission — grok's OMITTED default is "low" (it thinks, billed additively per xAI, so -// control:none would silently cost ~444 reasoning tokens/edit). It must not trip the echo-mine -// guard (xAI is not echo-prone: the editor input is a Russian draft), and the edit stage of both -// boevoy pipelines must be grok-4.3, while c2's select (judge) stage stays the glm-5 placeholder. -func TestBoevoyConfigGrokEditorExplicitNone(t *testing.T) { +// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the +// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and +// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning +// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an +// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission +// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not +// trip the echo-mine guard. c2's select (judge) stage stays the glm-5 placeholder. +func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) { m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load boevoy models.yaml: %v", err) } - const model = "grok-4.3" - cap := m.ResolveCapability(model) + // (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip). + const grok = "grok-4.3" + cap := m.ResolveCapability(grok) if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" { t.Errorf("%s must resolve to explicit reasoning_effort:\"none\" (control=effort, off_effort=none), got control=%q off_effort=%q — control:none leaves grok's \"low\" default (thinks, additive billing)", - model, cap.Reasoning.Control, cap.Reasoning.OffEffort) + grok, cap.Reasoning.Control, cap.Reasoning.OffEffort) } - if why := m.echoMineViolation(model); why != "" { - t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", model, why) + if why := m.echoMineViolation(grok); why != "" { + t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why) } + // (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines. for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} { p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m) if err != nil { t.Fatalf("load %s: %v", pf, err) } - var editModel, selectModel string + var editModel, editReasoning, selectModel string for _, st := range p.Stages { switch st.Role { case "editor": - editModel = st.Model + editModel, editReasoning = st.Model, st.Reasoning case "judge": selectModel = st.Model } } - if editModel != "grok-4.3" { - t.Errorf("%s edit stage model = %q, want grok-4.3 (D1/D3 default; glm-5 was the placeholder)", pf, editModel) + if editModel != "glm-5" { + t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off из редакторов СНЯТ — no-op)", pf, editModel) + } + if editReasoning != "off" { + t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — таймауты ×3)", pf, editReasoning) } if pf == "pipeline-c2.yaml" && selectModel != "glm-5" { - t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot, untouched by task 3), got %q", pf, selectModel) + t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel) } } } diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index b5769c6..aaf0a6a 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -86,8 +86,24 @@ type Stage struct { // Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1). type Gates struct { - Coverage CoverageGate `yaml:"coverage"` - Glossary GlossaryGate `yaml:"glossary"` + Coverage CoverageGate `yaml:"coverage"` + Glossary GlossaryGate `yaml:"glossary"` + Sanitizer SanitizerGate `yaml:"sanitizer"` +} + +// SanitizerGate controls the output-sanitizer (D30.3): a deterministic verdict-axis +// gate on the FINAL chunk text that flags "instant unreadability" defects no other gate +// catches — leaked service preambles, trailing note/edit blocks, markdown ### headers, +// Latin-script insertions in the Russian output, and broken/split word forms (exp12 / +// flagman §5). Opt-in (default false), following the coverage gate's discipline (D12 Q4): +// when enabled a defect flips the chunk to flagged (D2 flag+skip — the garbage output +// never commits to TM/export). Its rules are tuned PRECISION over recall (each class fires +// only on a high-confidence signal), so a premature always-on default would false-flag +// legitimate prose. The rule VERSION is folded into the snapshot only when enabled +// (sanitizerVersion, mirroring coverage), so a rule edit is a loud --resnapshot — and it +// moves to verdictSnapshotID once content-addressed resume lands (D15.2). +type SanitizerGate struct { + Enabled bool `yaml:"enabled"` } // GlossaryGate controls the memory-bank post-check (E1). The post-check ALWAYS runs diff --git a/backend/internal/pipeline/chunkrun.go b/backend/internal/pipeline/chunkrun.go index 2e91d2f..23fbc94 100644 --- a/backend/internal/pipeline/chunkrun.go +++ b/backend/internal/pipeline/chunkrun.go @@ -23,9 +23,26 @@ import ( // re-pins loudly). The coverage gate runs ONLY on the TRANSLATOR role's output (vs the // original source): a monolingual editor legitimately restructures sentences, so gating // it against the source would false-flag a correct edit (see the role check below). -func (r *Runner) classifyOutput(role, source, output, finish string) classification { +func (r *Runner) classifyOutput(role, source, output, finish string, isFinal bool) classification { cls := classify(classifyInput{Source: source, Output: output, Finish: finish, TargetLang: r.Book.TargetLang}) - if !cls.ok() || !r.Pipeline.Gates.Coverage.Enabled { + if !cls.ok() { + return cls + } + // Output-sanitizer gate (D30.3): a deterministic verdict-axis check for "instant + // unreadability" defects no other gate catches — a leaked service preamble, a trailing + // note/edit block, a markdown ### header, a Latin-script insertion, a broken word form. + // Opt-in (Gates.Sanitizer). Runs ONLY on the FINAL stage's output (isFinal) — the text + // that SHIPS: an intermediate translator draft is legitimately rough and the EDITOR's job + // is to clean it, so sanitizing the draft would wrongly skip the recovering editor and drop + // the chunk to a placeholder (адверсариальное ревью). Runs on the completion text like the + // intrinsic classifier, so a resumed checkpoint re-derives the identical verdict for free; + // its rule version folds into the snapshot only when enabled (sanitizerSnapshot). + if isFinal && r.Pipeline.Gates.Sanitizer.Enabled { + if san := sanitizeOutput(output); san.total() > 0 { + return classification{FlagSanitizerDefect, san.summary()} + } + } + if !r.Pipeline.Gates.Coverage.Enabled { return cls } // The coverage gate compares the output against the ORIGINAL source, which is a diff --git a/backend/internal/pipeline/disposition.go b/backend/internal/pipeline/disposition.go index d80c169..56c8fe6 100644 --- a/backend/internal/pipeline/disposition.go +++ b/backend/internal/pipeline/disposition.go @@ -81,6 +81,15 @@ const ( // retryable (a same-model retry re-produces the same rendering) and NOT auto- // escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9). FlagGlossaryMiss FlagReason = "glossary_miss" + + // FlagSanitizerDefect is the output-sanitizer verdict (D30.3, sanitizer.go): the stage + // output carries an "instant unreadability" defect no other gate catches — a leaked + // service preamble, a trailing note/edit block, a markdown ### header, a Latin-script + // insertion, or a broken word form. Emitted ONLY when the opt-in Gates.Sanitizer is + // enabled; the contaminated output is flagged (D2 flag+skip) so it never commits to + // TM/export. NOT retryable (a same-model retry re-produces the artifact) and NOT + // escalatable in v1 (the editor is pinned; a redrive surfaces the defect to a human). + FlagSanitizerDefect FlagReason = "sanitizer_defect" ) // classifierVersion versions the INTRINSIC classify() verdict logic — the refusal diff --git a/backend/internal/pipeline/escalation.go b/backend/internal/pipeline/escalation.go index a9f5504..6bd13f6 100644 --- a/backend/internal/pipeline/escalation.go +++ b/backend/internal/pipeline/escalation.go @@ -22,6 +22,14 @@ import ( // draft→deepseek-v4-pro hops on acceptance stage A: they inherited 2048/3291 and // returned length). 0 floor (reasoning-off models) leaves the budget untouched. // Applied BEFORE the request_hash so it is wire-visible and snapshot-folded. +// +// ⚠ LATENT INTERACTION (D28.3б, note not a defect — no floored local model exists today): a +// `provider_local` kind overrides max_tokens on the wire AFTER the request-hash (prov.MaxTokens, +// snapshot.go ProviderMaxTok fold), so for a FLOORED local model the provider override would win +// over this floor on the actual wire. Both are in the snapshot (floor via Capability.MinMaxTokens, +// override via ProviderMaxTok), so it is a loud --resnapshot either way — but if a local model ever +// gets a min_max_tokens floor, decide explicitly whether the provider max_tokens override or the +// floor takes precedence (today the local provider max_tokens=8192 already covers thinking budgets). func (r *Runner) applyModelFloor(base int, model string) int { if floor := r.Models.MinMaxTokens(model); floor > base { return floor @@ -76,7 +84,7 @@ type escalationOutcome struct { // (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never // reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is // the attempt loop's terminal (flagged) attempt. -func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt) (escalationOutcome, error) { +func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt, isFinal bool) (escalationOutcome, error) { var out escalationOutcome if !primary.cls.Reason.escalatable() || st.EscalateTo == "" { return out, nil @@ -108,7 +116,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri if !mayHop { return out, nil } - fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true) + fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal) if err != nil { // An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book // (and re-abort on every resume): the chunk is already flagged, so keep diff --git a/backend/internal/pipeline/golden_test.go b/backend/internal/pipeline/golden_test.go index d62a316..2f58b16 100644 --- a/backend/internal/pipeline/golden_test.go +++ b/backend/internal/pipeline/golden_test.go @@ -33,22 +33,31 @@ import ( // CJK-echo draft cured by the single-hop escalation re-gate (ch2), and a hard refusal // where the fallback also refuses → flag + skipped edit + exit 2 (ch3). // -// D23.4 note: the nested key here resolves to CONFIRMED — the capture shows ambiguous=0 -// on every chunk (both keys are longest-match approved surfaces, not collision-prone -// downgrades). A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) -// is an OPTIONAL fixture extension, deliberately NOT added in this pass: it would be a -// fresh ratified golden capture of its own, and this package's re-capture is scoped to -// the D24.3/D24.4 diff-class (floor max_tokens + post-check verdicts). +// D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now +// PINNED by golden, not only by the dedicated unit tests — +// • FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model +// capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move, +// and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000); +// • UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the +// NOMINATIVE «старик» → postcheck_miss=0 only because the base-dst∪decl union checks the +// base (revert the union → this chunk false-flags a miss); +// • SANITIZER: ch6 draft leaks a service preamble → the output-sanitizer gate (ON in the +// fixture pipeline) flags it FlagSanitizerDefect and the edit is skipped. +// A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) remains an OPTIONAL +// future extension, deliberately NOT added here. // // Update (ONLY on a deliberate, ratified behaviour change — never to "fix" a red // refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden const goldenFile = "testdata/golden/capture.golden" -// goldenEchoMarker / goldenRefusalMarker are unique substrings of chapters 2/3 of the -// fixture source; the mock provider keys its echo/refusal behaviour off them. +// goldenEchoMarker / goldenRefusalMarker / goldenUnionMarker / goldenSanitizerMarker are +// unique substrings of chapters 2/3/5/6 of the fixture source; the mock keys its +// echo / refusal / union / sanitizer behaviour off them. const ( - goldenEchoMarker = "響動計画" - goldenRefusalMarker = "拒絶計画" + goldenEchoMarker = "響動計画" + goldenRefusalMarker = "拒絶計画" + goldenUnionMarker = "老人の秘密" // ch5 — oblique-only decl union case + goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case ) // goldenRespond is the deterministic mock provider brain. The response text is a pure @@ -66,6 +75,16 @@ func goldenRespond(body string) (text, finish string) { case strings.Contains(body, goldenRefusalMarker): // Both the primary and the fallback refuse → the chunk stays flagged (1 hop). return "Не могу помочь с этим фрагментом.", "refusal" + case strings.Contains(body, goldenSanitizerMarker) && !isEditBody(body): + // ch6 draft ALSO leaks a preamble AND carries a keying token («ПРЕАМБУЛА-СЦЕНА»). The + // sanitizer runs ONLY on the FINAL (edit) output (адверсариальное ревью: не санитайзить + // промежуточный черновик), so with isFinal this draft stays OK and the edit is the one + // flagged. This PINS isFinal: revert «isFinal &&» in classifyOutput and the DRAFT flags + // instead (edit skipped) → golden diff. + return "Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь.", "stop" + case strings.Contains(body, goldenUnionMarker) && !isEditBody(body): + // ch5 draft carries a keying token («СТАРИК-СЦЕНА») the edit branch below keys on. + return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". СТАРИК-СЦЕНА Судзуки увидел старика.", "stop" case strings.Contains(body, goldenEchoMarker) && !isEditBody(body): if req.Model == "fake-fallback" { // The escalation hop returns a clean translation → the re-gate passes it. @@ -73,6 +92,14 @@ func goldenRespond(body string) (text, finish string) { } // The primary draft echoes CJK instead of translating → cjk_artifact. return "夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。", "stop" + case isEditBody(body) && strings.Contains(body, "ПРЕАМБУЛА-СЦЕНА"): + // ch6 edit leaks a service preamble → the output-sanitizer flags the FINAL stage + // (FlagSanitizerDefect). Colon after «фрагмента» is the high-precision signal. + return "Вот перевод фрагмента: Судзуки открыл дверь книгохранилища.", "stop" + case isEditBody(body) && strings.Contains(body, "СТАРИК-СЦЕНА"): + // ch5 edit — the FINAL text carries the NOMINATIVE «старик» (not an oblique decl + // form of 老人→старик), exercising the base-dst∪decl union → postcheck_miss=0. + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки увидел, как в тени ждал старик.", "stop" case isEditBody(body): return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop" default: @@ -107,9 +134,11 @@ models: fake-model: provider: fake price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } + capabilities: { min_max_tokens: 4000 } # D28.2: floor the PRIMARY model — pins floored max_tokens on the wire fake-fallback: provider: fake price: { input_per_m: 3.0, cached_per_m: 0.3, cache_write_per_m: 0, output_per_m: 6.0 } + capabilities: { min_max_tokens: 6000 } # D28.2: a DISTINCT hop floor — pins that the hop re-floors by ITS own model `, time.Now().UTC().Format("2006-01-02"), providerURL)) return filepath.Join(dir, "book.yaml") } diff --git a/backend/internal/pipeline/mempostcheck.go b/backend/internal/pipeline/mempostcheck.go index dd06e45..9f12db9 100644 --- a/backend/internal/pipeline/mempostcheck.go +++ b/backend/internal/pipeline/mempostcheck.go @@ -81,7 +81,8 @@ func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postchec // («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14 // requirement): the stored decl forms carry the inflections a boundary regexp would miss. // -// The accepted set is the UNION of the base dst AND the stored decl forms (D24.3/D24.4): +// The accepted set is the UNION of the base dst AND the stored decl forms (D24.4 — D24.3 is +// the max_tokens floor, a different fix; the union is the post-check verdict change): // the base translation is itself an approved form, so it is ALWAYS checked — not only as a // fallback when decl is empty. Checking it only on empty decl false-flagged 37/55 // acceptance-stage-A misses (nominative_gap): an approved dst present in the NOMINATIVE diff --git a/backend/internal/pipeline/runner_memory_test.go b/backend/internal/pipeline/runner_memory_test.go index 9b7b8b4..90b52fb 100644 --- a/backend/internal/pipeline/runner_memory_test.go +++ b/backend/internal/pipeline/runner_memory_test.go @@ -309,19 +309,31 @@ func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) { } } -// TestEditorPromptIsMonolingual pins decisions-log D1: the real editor prompt sees only the -// draft (+ the approved glossary injected as dst constraints by the runner), never the -// source. A reintroduced {{text}} would silently re-anchor the editor to the source and -// breed кальки (the main translationese disease D1 exists to avoid). -func TestEditorPromptIsMonolingual(t *testing.T) { +// TestEditorPromptIsBilingual pins the D30.1 flip (D1 monolingual → BILINGUAL editor, +// supersede D17.в): exp12 proved the monolingual editor is both passive and structurally +// blind to draft distortions (it cannot know «ударил головой» is a 磕头 kowtow without the +// source). The default editor.md now sees BOTH the source ({{text}}) and the draft +// ({{draft}}). The monolingual variant is PRESERVED as prompts/editor-mono.md (the D13.1 +// confirming pilot arm) — it must NOT reference {{text}}. +func TestEditorPromptIsBilingual(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor.md")) if err != nil { t.Fatalf("read editor.md: %v", err) } - if strings.Contains(string(raw), "{{text}}") { - t.Error("editor.md must NOT reference {{text}} (D1: the editor is monolingual — the source is not injected)") + if !strings.Contains(string(raw), "{{text}}") { + t.Error("editor.md must reference {{text}} (D30.1: the editor is now BILINGUAL — it sees the source)") } if !strings.Contains(string(raw), "{{draft}}") { t.Error("editor.md must reference {{draft}} (it edits the draft)") } + mono, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor-mono.md")) + if err != nil { + t.Fatalf("read editor-mono.md: %v (D30.1: the monolingual variant must be preserved for the D13.1 pilot arm)", err) + } + if strings.Contains(string(mono), "{{text}}") { + t.Error("editor-mono.md must NOT reference {{text}} (it is the monolingual variant)") + } + if !strings.Contains(string(mono), "{{draft}}") { + t.Error("editor-mono.md must reference {{draft}}") + } } diff --git a/backend/internal/pipeline/sanitizer.go b/backend/internal/pipeline/sanitizer.go new file mode 100644 index 0000000..99dabd1 --- /dev/null +++ b/backend/internal/pipeline/sanitizer.go @@ -0,0 +1,402 @@ +package pipeline + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode" +) + +// sanitizer.go: the output-sanitizer (D30.3) — a deterministic verdict-axis gate on the +// FINAL chunk text that catches the "instant unreadability" defect classes NO existing +// gate detects (exp12 root-cause + flagman §5). It is OPT-IN (Gates.Sanitizer.Enabled), +// following the coverage gate's discipline: when enabled a defect flips the chunk to +// flagged (D2 flag+skip — the contaminated output never commits to TM/export), else it is +// a no-op. Pure and deterministic (no time/rand, sorted detail) so a resume re-derives the +// identical verdict — its rule version folds into the snapshot only when enabled +// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once +// content-addressed resume lands (D15.2). +// +// Five classes, each tuned PRECISION over recall (fire only on a high-confidence signal; +// the ambiguous middle stays silent — a false flag turns a good chunk into an export +// placeholder, worse than a missed defect for an opt-in readability gate): +// +// 1. leading service preamble — «Вот отредактированный перевод: …» (gemini 6/6, exp12 §4.1) +// 2. trailing note/edit block — appended «Примечание:/Внесённые правки:/Что изменено:» meta +// 3. markdown ### header — «### Глава 1» (8/54 finals of stage A — D29 finding) +// 4. Latin-script insertion — an untranslated Latin phrase/heavy Latin in the ru output +// 5. broken word form — homoglyph-mixed or invalid-sign / split-duplicated tokens +// +// HONEST recall gap (documented, precision over recall): the broken-word class catches only +// the DETERMINISTIC signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin token, +// junction-duplicated split). A word broken WITHOUT an invalid character («Первок предок» +// split into two individually-valid tokens) or a grammatical-but-wrong form («валуне») is +// NOT offline-detectable without a morphology/dictionary pass (Ф2) — left silent. + +// sanitizerVersion versions the rules below (folded into the snapshot when the gate is +// enabled, like cheapGateVersion/coverageGateVersion): editing any pattern shifts the +// resolved disposition of flagged chunks, so a bump is a loud --resnapshot. [D15.2 note: +// this is a VERDICT version — once content-addressed resume lands it moves to +// verdictSnapshotID and a rule edit re-classifies free instead of re-paying the book.] +const sanitizerVersion = "sanitizer-v1" + +// sanitizer tuning constants (versioned by sanitizerVersion). +const ( + // trailingRegionLines: a note/edit header counts only when it sits in the LAST N + // non-empty lines (a meta block the model appended), so a legitimate mid-narrative + // «Примечание» in dialogue does not fire. + trailingRegionLines = 6 + // latinPhraseWords: a run of this many SPACE-adjacent Latin word-tokens is a leaked + // untranslated phrase/sentence (a couple of proper names never reaches it). Set to 5 + // (адверсариальное ревью): a ≤4-word quoted foreign motto («sic transit gloria mundi») + // is legit source-preserved content and must NOT be dropped. ⚠ ACCEPTED recall/precision + // edge: a deliberately-quoted foreign phrase of ≥5 words («to be or not to be») still + // trips it — rare, and the gate is opt-in (a redrive surfaces it to a human). + latinPhraseWords = 5 + // latinHeavyShare / latinHeavyTokens: heavy Latin contamination — Latin letters are a + // large share of all letters AND there are several Latin tokens (both required, so a + // short chunk with one long Latin brand name does not trip the share alone). + latinHeavyShare = 0.25 + latinHeavyTokens = 5 + // junctionOverlap: minimal suffix==prefix overlap between two adjacent Cyrillic tokens + // to call it a split-duplication artifact («Первопред предок»); exact duplicates + // (emphatic «хорошо хорошо») are excluded. + junctionOverlap = 4 +) + +// sanitizerResult is the per-chunk outcome: a count per class plus deterministic detail. +type sanitizerResult struct { + Preamble int `json:"preamble,omitempty"` + TrailingNote int `json:"trailing_note,omitempty"` + MarkdownHeader int `json:"markdown_header,omitempty"` + LatinInsert int `json:"latin_insert,omitempty"` + BrokenWord int `json:"broken_word,omitempty"` + Detail []string `json:"detail,omitempty"` +} + +func (s sanitizerResult) total() int { + return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord +} + +// summary is the human-readable disposition detail for a sanitizer flag (deterministic — +// Detail is sorted in sanitizeOutput). Kept short (the first two findings) for the log/row. +func (s sanitizerResult) summary() string { + if len(s.Detail) == 0 { + return "output-sanitizer defect" + } + if len(s.Detail) > 2 { + return strings.Join(s.Detail[:2], "; ") + fmt.Sprintf("; …(+%d)", len(s.Detail)-2) + } + return strings.Join(s.Detail, "; ") +} + +// sanitizeOutput runs all five classes over one chunk's FINAL translated text. +func sanitizeOutput(text string) sanitizerResult { + var r sanitizerResult + if det := detectLeadingPreamble(text); det != "" { + r.Preamble++ + r.Detail = append(r.Detail, det) + } + if det := detectTrailingNote(text); det != "" { + r.TrailingNote++ + r.Detail = append(r.Detail, det) + } + if n, det := detectMarkdownHeaders(text); n > 0 { + r.MarkdownHeader = n + r.Detail = append(r.Detail, det...) + } + if det := detectLatinInsertion(text); det != "" { + r.LatinInsert++ + r.Detail = append(r.Detail, det) + } + if n, det := detectBrokenWords(text); n > 0 { + r.BrokenWord = n + r.Detail = append(r.Detail, det...) + } + sort.Strings(r.Detail) + return r +} + +// --- 1. leading service preamble ------------------------------------------------ + +// leadingPreamblePatterns match a service preamble at the very START of the output — the +// «Вот отредактированный перевод: …» leak (gemini 6/6, exp12 §4.1). PRECISION over recall +// (адверсариальное ревью: a lax rule dropped clean chapter openings like «Вот такой вариант +// развития — самый вероятный.», «Вот его вариант: он молча ушёл.»). Two guards make it tight: +// 1. terminator is a COLON only — the em-dash «—» is ubiquitous in narration, so it is NOT +// a terminator (a service preamble is a labeled prefix ending in «:»); +// 2. the phrase must be a genuine TRANSLATION/EDIT service label, not merely «lead + common +// noun»: either an edit-adjective (отредактированн/исправленн/улучшенн) + a перевод/текст/ +// вариант noun, or «перевод фрагмента/текста/…», or «ниже приведён … перевод/текст». +// Go RE2 \w/\b are ASCII-only, so Cyrillic uses [а-яё]. +var leadingPreamblePatterns = []*regexp.Regexp{ + // «Вот/Представляю/Привожу … отредактированный/исправленный перевод/текст/вариант …:» + // (the edit-adjective front-gate holds precision, so the tail-to-colon may be long — the + // gemini leak carries «… с соблюдением всех терминов из глоссария:»). + regexp.MustCompile(`(?is)^\s*(?:вот|представляю|привожу|держите)\s+[^\n:]{0,40}?(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`), + // «Отредактированный/Исправленный перевод/текст/вариант …:» (label at the very start) + regexp.MustCompile(`(?is)^\s*(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`), + // «(Вот) перевод фрагмента/текста/отрывка/главы/черновика:» + regexp.MustCompile(`(?is)^\s*(?:вот\s+)?перевод\s+(?:фрагмента|текста|отрывка|главы|черновика)\s*:`), + // «Ниже приведён/представлен … перевод/отредактированный текст:» + regexp.MustCompile(`(?is)^\s*ниже\s+(?:привед[её]н|представлен|дан|след)[а-яё]*\s+[^\n:]{0,40}?(?:перевод|отредактированн|текст)[а-яё]*[^\n:]{0,20}?:`), +} + +func detectLeadingPreamble(text string) string { + t := strings.TrimSpace(text) + for _, re := range leadingPreamblePatterns { + if loc := re.FindStringIndex(t); loc != nil { + return "ведущая служебная преамбула: " + preview(strings.TrimSpace(t[:loc[1]])) + } + } + return "" +} + +// --- 2. trailing note / edit block ---------------------------------------------- + +// trailingNoteRE matches a note/edit meta-HEADER at line start: «Примечание:», «Заметки +// переводчика», «Комментарий:», «(прим. перев.)». PRECISION over recall (адверсариальное +// ревью: «Комментатор захлёбывался…», «Заметка белела на столе.», «Пояснения не требовалось.», +// «Сноска объясняла обычай.» are ordinary narrative nouns/gerunds — NOT headers). So the header +// word MUST be followed by a COLON, or be «примечание/заметки/комментарий + переводчика/редактора», +// or be the «(прим. перев./ред.)» marker. Only counted inside the trailing region (below). +var trailingNoteRE = regexp.MustCompile(`(?i)^[*_>\s-]{0,4}(?:(?:примечани[ея]|заметк[аи]|комментари[йяю]|пояснени[ея]|сноск[аи])\s*:|(?:примечани[ея]|заметк[аи]|комментари[йяю])\s+(?:переводчик|редактор)[а-яё]*|прим\.\s*(?:перев|ред)\.?)`) + +// editMetaAnywhereRE matches UNAMBIGUOUS editor meta-commentary that is a defect wherever it +// appears (not only trailing): «Внесённые правки:», «Что изменено:», «Список правок:». The +// generic «Изменения:/Правки:» alone were REMOVED (адверсариальное ревью — «Изменения — вот что +// пугало его.» / «Правка — дело тонкое.» are common narrative openers), keeping only the +// editor-specific compound phrases that never occur in prose. +var editMetaAnywhereRE = regexp.MustCompile(`(?im)^[*_>\s-]{0,4}(?:(?:внесённ|внесен)[а-яё]+\s+правк|что\s+(?:было\s+)?(?:изменен|исправлен)|список\s+(?:правок|изменений)|список\s+внесённых)`) + +func detectTrailingNote(text string) string { + if loc := editMetaAnywhereRE.FindStringIndex(text); loc != nil { + return "утёкший блок правок редактора: " + preview(strings.TrimSpace(firstLineAt(text, loc[0]))) + } + lines := nonEmptyLines(text) + start := len(lines) - trailingRegionLines + if start < 0 { + start = 0 + } + for _, ln := range lines[start:] { + if trailingNoteRE.MatchString(ln) { + return "хвостовой блок заметок/примечаний: " + preview(strings.TrimSpace(ln)) + } + } + return "" +} + +// --- 3. markdown ### header ----------------------------------------------------- + +// markdownHeaderRE matches a markdown ATX header line («### Глава», «# Title»): 1–6 hashes, +// a space, then non-space content. «#1» / «№5» / a lone «#» do not match (no space+text). +var markdownHeaderRE = regexp.MustCompile(`(?m)^\s{0,3}#{1,6}\s+\S`) + +func detectMarkdownHeaders(text string) (int, []string) { + var det []string + n := 0 + for _, ln := range strings.Split(text, "\n") { + if markdownHeaderRE.MatchString(ln) { + n++ + det = append(det, "markdown-заголовок в выходе: "+preview(strings.TrimSpace(ln))) + } + } + return n, det +} + +// --- 4. Latin-script insertion -------------------------------------------------- + +// detectLatinInsertion flags an untranslated Latin PHRASE (≥ latinPhraseWords Latin words +// separated only by SINGLE SPACES — a real «the quick brown fox», not an alphanumeric id) OR +// heavy Latin contamination (Latin ≥ latinHeavyShare of all letters AND ≥ latinHeavyTokens +// Latin tokens). Requiring space-adjacency is load-bearing: a hex/id run like «138fd5c384e3» +// or «a1b2c3d4» yields several single-letter Latin tokens with NO space between them, which +// must NOT read as a Latin phrase (a false positive the golden fixture's tags exposed). A +// handful of Latin proper names stays silent. +func detectLatinInsertion(text string) string { + toks := scriptTokens(text) + maxRun, run, latTokens := 0, 0, 0 + for _, tk := range toks { + switch { + case tk.lat: + if tk.adjacent && run > 0 { + run++ // a Latin word separated from the previous Latin word by one space + } else { + run = 1 // phrase start (digit/punct separator or a non-Latin predecessor) + } + latTokens++ + if run > maxRun { + maxRun = run + } + case tk.cyr || tk.mixed: + run = 0 + } + } + if maxRun >= latinPhraseWords { + return "латинская вставка (фраза из подряд идущих латинских слов)" + } + latinLetters, cyrLetters := 0, 0 + for _, ru := range text { + if unicode.Is(unicode.Latin, ru) { + latinLetters++ + } else if unicode.Is(unicode.Cyrillic, ru) { + cyrLetters++ + } + } + total := latinLetters + cyrLetters + if total > 0 && latTokens >= latinHeavyTokens && float64(latinLetters)/float64(total) >= latinHeavyShare { + return "латиница-врезки: тяжёлая доля латиницы в ru-выходе" + } + return "" +} + +// --- 5. broken word forms ------------------------------------------------------- + +// invalidSignRE matches Cyrillic sign bigrams that never occur in valid Russian: a hard/soft +// sign at a word START that is FOLLOWED BY A LETTER (a broken token like «ьзал»), or two signs +// adjacent (ъь/ьъ/ьь/ъъ). Requiring the following letter excludes a STANDALONE «ъ»/«ь» — a +// metalinguistic mention of the letter itself, «Буква ъ называлась ером.» (адверсариальное +// ревью nit). High precision otherwise: word-initial-sign-then-letter and doubled signs are +// impossible in well-formed text. \P{L} (non-letter), not RE2's ASCII-only \b. +var invalidSignRE = regexp.MustCompile(`(?i)(?:^|\P{L})[ъь][а-яё]|[ъь][ъь]`) + +func detectBrokenWords(text string) (int, []string) { + var det []string + seen := map[string]bool{} + add := func(s string) { + if !seen[s] { + seen[s] = true + det = append(det, s) + } + } + // (a) invalid Cyrillic sign bigrams. + if invalidSignRE.MatchString(text) { + add("невалидная кириллическая биграмма (мягкий/твёрдый знак в начале слова или подряд)") + } + // (b) mixed Cyrillic+Latin within one letter-run token (homoglyph artifact) and + // (c) junction-duplicated adjacent split — walk the letter tokens once. + toks := scriptTokens(text) + for _, tk := range toks { + if tk.mixed { + add("смешение кириллицы и латиницы в одном слове (гомоглиф): " + preview(tk.text)) + } + } + for i := 1; i < len(toks); i++ { + if !toks[i].adjacent { + continue + } + a, b := toks[i-1], toks[i] + if a.cyr && b.cyr && a.text != b.text && junctionDuplicated(a.text, b.text) { + add("похоже на разорванное/сдвоенное слово: " + preview(a.text+" "+b.text)) + } + } + return len(det), det +} + +type scriptToken struct { + text string + cyr bool // Cyrillic-only + lat bool // Latin-only + mixed bool // both scripts in one letter run (homoglyph artifact) + adjacent bool // separated from the previous token by exactly a single space (junction check) +} + +// scriptTokens splits text into maximal letter-run tokens (letters only; digits/hyphens/ +// spaces/punctuation are separators), tagging Cyrillic-only / Latin-only / mixed-script and +// whether the token is separated from the previous token by exactly a single space +// (adjacency — a duplication artifact sits inside a phrase, not across punctuation/newline). +func scriptTokens(text string) []scriptToken { + var out []scriptToken + var b strings.Builder + hasLat, hasCyr := false, false + gap := "" // separator run since the previous token: "" none yet, " " one space, "x" other + flush := func() { + if b.Len() > 0 { + out = append(out, scriptToken{ + text: b.String(), + cyr: hasCyr && !hasLat, + lat: hasLat && !hasCyr, + mixed: hasLat && hasCyr, + adjacent: gap == " ", + }) + b.Reset() + gap = "" + } + hasLat, hasCyr = false, false + } + for _, ru := range text { + switch { + case unicode.Is(unicode.Latin, ru): + b.WriteRune(ru) + hasLat = true + case unicode.Is(unicode.Cyrillic, ru): + b.WriteRune(ru) + hasCyr = true + default: + flush() + if ru == ' ' && gap == "" { + gap = " " + } else { + gap = "x" // newline / punctuation / tab / a second space breaks adjacency + } + } + } + flush() + return out +} + +// junctionDuplicated reports whether adjacent tokens a, b share a PROPER suffix==prefix +// overlap of ≥ junctionOverlap runes (a split-duplication artifact like «Первопред предок», +// overlap «пред»). CONTAINMENT is EXCLUDED (адверсариальное ревью major): if one token is a +// prefix of the other («старик старика», «город города» — a noun + its own inflection, i.e. +// legit polyptoton), the suffix==prefix loop would match at the containment length and +// false-flag it. A true broken split has a MID overlap where NEITHER token contains the +// other. Both must be reasonably long so a short common ending does not trip it. +func junctionDuplicated(a, b string) bool { + al := []rune(strings.ToLower(a)) + bl := []rune(strings.ToLower(b)) + if len(al) < junctionOverlap+1 || len(bl) < junctionOverlap+1 { + return false + } + if strings.HasPrefix(string(bl), string(al)) || strings.HasPrefix(string(al), string(bl)) { + return false // containment = polyptoton (word + its inflection), not a broken split + } + maxK := len(al) + if len(bl) < maxK { + maxK = len(bl) + } + // A proper overlap cannot equal a whole token (that would be containment, excluded above), + // so cap the search below the shorter length. + if maxK > 0 { + maxK-- + } + for k := maxK; k >= junctionOverlap; k-- { + if runesEqual(al[len(al)-k:], bl[:k]) { + return true + } + } + return false +} + +// --- shared helpers ------------------------------------------------------------- + +func nonEmptyLines(text string) []string { + var out []string + for _, ln := range strings.Split(text, "\n") { + if strings.TrimSpace(ln) != "" { + out = append(out, ln) + } + } + return out +} + +// firstLineAt returns the line containing byte offset off. +func firstLineAt(text string, off int) string { + start := strings.LastIndexByte(text[:off], '\n') + 1 + end := strings.IndexByte(text[off:], '\n') + if end < 0 { + return text[start:] + } + return text[start : off+end] +} diff --git a/backend/internal/pipeline/sanitizer_test.go b/backend/internal/pipeline/sanitizer_test.go new file mode 100644 index 0000000..afdb1fe --- /dev/null +++ b/backend/internal/pipeline/sanitizer_test.go @@ -0,0 +1,205 @@ +package pipeline + +import ( + "strings" + "testing" +) + +// sanitizer_test.go — the output-sanitizer (D30.3) unit suite. Per the gate discipline +// (precision over recall) EVERY class asserts BOTH firing on the exp12/flagman defect and +// NON-firing on legitimate prose, so a future rule edit that starts false-flagging real +// Russian text fails loudly here. + +func TestSanitizerLeadingPreamble(t *testing.T) { + fire := []string{ + // gemini 6/6 leak (exp12 §4.1) — the canonical target. + "Вот отредактированный перевод с соблюдением всех терминов из глоссария:\n\nСудзуки шёл по коридору.", + "Вот отредактированный перевод:\nТекст главы.", + "Вот перевод фрагмента: Судзуки открыл дверь.", + "Ниже приведён отредактированный текст:\nГлава началась.", + "Отредактированный перевод: Судзуки вошёл.", + "Представляю исправленный вариант перевода: он ушёл.", + } + for _, s := range fire { + if got := sanitizeOutput(s); got.Preamble == 0 { + t.Errorf("preamble not detected in %q", preview(s)) + } + } + clean := []string{ + "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", + "Вот и настал тот день, которого он так ждал.", // «Вот и…» narrative — no service phrase + "Вот дом стоял на холме, окружённый старыми соснами.", // «Вот дом…» + "— Вот твой перевод, — сказал он и протянул конверт.", // dialogue about a translation, not a preamble label + "Перевод стрелки часов занял у него всё утро.", // «Перевод» as a common noun, no service label + "Ниже по реке стоял старый мост, к которому вела тропа.", // «Ниже…» geographic + // адверсариальное ревью false positives — must NOT fire (em-dash terminator + common nouns): + "Вот такой вариант развития событий — самый вероятный.", // вот + вариант + em-dash, no edit-adjective + "Ниже текста была подпись — размашистая.", // ниже + текст + em-dash, not «ниже приведён … перевод:» + "Вот новая версия карты — с пометками отца.", // вот + версия + em-dash + "Итоговый текст письма — три строки.", // итоговый + текст + em-dash + "Вот его вариант: он молча ушёл.", // вот + вариант + COLON, but no edit-adjective / service phrase + } + for _, s := range clean { + if got := sanitizeOutput(s); got.Preamble != 0 { + t.Errorf("preamble FALSE POSITIVE in %q: %v", preview(s), got.Detail) + } + } +} + +func TestSanitizerTrailingNote(t *testing.T) { + fire := []string{ + "Судзуки открыл дверь.\n\nПримечание: имя героя оставлено без перевода.", + "Он ушёл в туман.\n\nВнесённые правки: заменил «фракции» на «школы».", + "Глава закончилась.\n\nЧто изменено: исправлена терминология гу.", + "Текст главы.\n\n*Примечание переводчика:* здесь игра слов.", + "Финал сцены.\n\nЗаметки редактора: стиль приведён к норме.", + "Список правок: 1) термины; 2) вёрстка.\nТекст главы дальше.", // edit-meta anywhere + } + for _, s := range fire { + if got := sanitizeOutput(s); got.TrailingNote == 0 { + t.Errorf("trailing note not detected in %q", preview(s)) + } + } + clean := []string{ + "Судзуки открыл дверь и вошёл в тёмный зал, где ждала тишина.", + "Примечательно, что никто не заметил его исчезновения в ту ночь.", // «Примечательно» ≠ «Примечание:» + "Он оставил заметку на столе и вышел, не оглядываясь назад.", // «заметку» mid-narrative, not a header + "— Что изменилось? — спросил он, оглядывая пустую комнату.", // dialogue, not «Что изменено:» + // адверсариальное ревью false positives — ordinary narrative nouns/gerunds ending a chapter: + "Комментатор захлёбывался от восторга, махая руками над головой.", // «Комментатор» ≠ «Комментарий:» + "Комментируя случившееся, старик лишь развёл руками в тишине.", // «Комментируя» gerund + "Заметка белела на столе, и никто не решался её тронуть.", // «Заметка» + no colon + "Пояснения не требовалось: всё было ясно без единого слова.", // «Пояснения» + no colon (the colon comes later) + "Сноска в конце страницы объясняла древний обычай клана.", // «Сноска» narrative subject + "Изменения — вот что пугало его больше всего на этом свете.", // «Изменения —» common narrative opener + "Правка — дело тонкое, и старый мастер знал это лучше всех.", // «Правка —» aphorism + } + for _, s := range clean { + if got := sanitizeOutput(s); got.TrailingNote != 0 { + t.Errorf("trailing note FALSE POSITIVE in %q: %v", preview(s), got.Detail) + } + } +} + +func TestSanitizerMarkdownHeader(t *testing.T) { + fire := []string{ + "### Глава 1\n\nСудзуки шёл по коридору.", + "# Пролог\n\nТекст.", + "Текст первой сцены.\n\n## Часть вторая\n\nПродолжение.", + } + for _, s := range fire { + if got := sanitizeOutput(s); got.MarkdownHeader == 0 { + t.Errorf("markdown header not detected in %q", preview(s)) + } + } + clean := []string{ + "Он купил дом №5 на этой улице за бесценок прошлой весной.", // «№5» not a header + "Комната #1 была заперта, и ключа ни у кого не нашлось.", // «#1» no space+text + "Язык C# он выучил ещё в университете, но давно забросил.", // «C#» mid-line + "Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.", + } + for _, s := range clean { + if got := sanitizeOutput(s); got.MarkdownHeader != 0 { + t.Errorf("markdown header FALSE POSITIVE in %q: %v", preview(s), got.Detail) + } + } +} + +func TestSanitizerLatinInsertion(t *testing.T) { + fire := []string{ + "Судзуки открыл книгу и прочитал: the quick brown fox jumps over.", // ≥4-word latin phrase + "He walked into the ancient library at dawn and found nothing.", // whole latin sentence + } + for _, s := range fire { + if got := sanitizeOutput(s); got.LatinInsert == 0 { + t.Errorf("latin insertion not detected in %q", preview(s)) + } + } + clean := []string{ + "Судзуки купил iPhone и MacBook, чтобы работать в дороге по ночам.", // 2 latin brand names + "На экране светилась надпись Google, и он нажал на неё пальцем.", // single latin name + "Он listened внимательно.", // 1 stray latin word + "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", + "Идентификатор 138fd5c384e3 и код a1b2c3d4e5f6 записаны в журнале.", // hex/id: single-letter latin tokens separated by digits — NOT a phrase + "На гербе была выбита надпись: sic transit gloria mundi.", // 4-word quoted Latin motto — legit preserved (адверсариальное ревью) + } + for _, s := range clean { + if got := sanitizeOutput(s); got.LatinInsert != 0 { + t.Errorf("latin insertion FALSE POSITIVE in %q: %v", preview(s), got.Detail) + } + } +} + +func TestSanitizerBrokenWord(t *testing.T) { + fire := []string{ + "Стольь высокая башня терялась в облаках над городом.", // doubled soft sign ьь + "Он вошёл в ьзал и замер на пороге, не решаясь шагнуть.", // soft sign word-initial + "Древний Первопред предок восстал из пепла забытых веков.", // junction-duplicated split «Первопред|предок» + } + for _, s := range fire { + if got := sanitizeOutput(s); got.BrokenWord == 0 { + t.Errorf("broken word not detected in %q", preview(s)) + } + } + // Mixed Cyrillic+Latin homoglyph in one token (latin 'p'/'e'/'o' inside a Cyrillic word). + if got := sanitizeOutput("Пеpвок предок восстал из пепла."); got.BrokenWord == 0 { + t.Errorf("mixed-script homoglyph not detected") + } + clean := []string{ + "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", + "— Хорошо, хорошо, — устало ответил он, поднимая обе руки вверх.", // emphatic exact repeat, NOT a split + "Молоко около дома скисло за одну жаркую летнюю ночь под навесом.", // adjacent words, no ≥4 junction overlap + "На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap) + // адверсариальное ревью false positives: + "Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика): containment, NOT a split + "Город города не выдаёт — так говаривал его дед у очага.", // город + города: containment + "Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone) + "Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat (case-differing) — containment excludes it + } + for _, s := range clean { + if got := sanitizeOutput(s); got.BrokenWord != 0 { + t.Errorf("broken word FALSE POSITIVE in %q: %v", preview(s), got.Detail) + } + } +} + +// TestSanitizerBrokenWordRecallGap documents the honest boundary of the DETERMINISTIC +// minimum (D30.3: precision over recall, «это ФЛАГГЕР»): a word split into two +// individually-valid tokens with NO invalid sign, NO homoglyph and NO ≥4-rune junction +// overlap — the bare «Первок предок» from exp12 — is NOT offline-detectable without a +// morphology/dictionary pass (Ф2). It is caught only when the split leaves a junction +// overlap («Первопред предок») or an invalid character. This test PINS that gap so a future +// stronger heuristic is a conscious change, not an accident. +func TestSanitizerBrokenWordRecallGap(t *testing.T) { + if got := sanitizeOutput("Первок предок восстал из пепла забытых веков."); got.BrokenWord != 0 { + t.Errorf("unexpected: the pure-orthographic split «Первок предок» is documented as a recall gap, but it fired: %v", got.Detail) + } +} + +// TestSanitizerCleanNarrativePassesEntirely guards the whole gate against false positives +// on a realistic clean Russian passage with dialogue, names and numbers. +func TestSanitizerCleanNarrative(t *testing.T) { + passage := strings.Join([]string{ + "Судзуки медленно шёл по длинным коридорам Академии магии.", + "", + "— Сегодня я наконец открою дверь книгохранилища, — прошептал он.", + "", + "За окном лил серебристый дождь, собираясь в ручьи на брусчатке двора.", + "Башня библиотеки тонула в тумане, и вершины её не было видно.", + }, "\n") + if got := sanitizeOutput(passage); got.total() != 0 { + t.Fatalf("clean narrative flagged by the sanitizer: %+v", got.Detail) + } +} + +// TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe). +func TestSanitizerDeterministic(t *testing.T) { + s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка." + a, b := sanitizeOutput(s), sanitizeOutput(s) + if a.total() != b.total() || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") { + t.Fatalf("sanitizeOutput not deterministic: %v vs %v", a, b) + } + if a.total() == 0 { + t.Fatalf("expected the multi-defect fixture to flag") + } +} diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go index 0472325..fa42971 100644 --- a/backend/internal/pipeline/snapshot.go +++ b/backend/internal/pipeline/snapshot.go @@ -57,6 +57,25 @@ func (r *Runner) coverageSnapshot() coverageSnap { return cov } +// sanitizerSnap freezes the output-sanitizer gate (D30.3) inside the snapshot, mirroring +// coverageSnap: {enabled:false} when off (so toggling on is a visible --resnapshot), the +// algorithm version when on. It changes a checkpoint's RESOLVED disposition (a defect flips +// the chunk to flagged), never the wire, so a rule edit is a loud re-pin, not a silent +// re-verdict on resume. Folded ONLY when enabled — tweaking a disabled gate must not force a +// re-pin (it produces no verdicts while off). Moves to verdictSnapshotID with D15.2. +type sanitizerSnap struct { + Enabled bool `json:"enabled"` + Version string `json:"version,omitempty"` +} + +func (r *Runner) sanitizerSnapshot() sanitizerSnap { + s := sanitizerSnap{Enabled: r.Pipeline.Gates.Sanitizer.Enabled} + if s.Enabled { + s.Version = sanitizerVersion + } + return s +} + // memoryVersion is the content-hash of the deterministically materialized injected // memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm // versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the @@ -178,8 +197,12 @@ func (r *Runner) snapshotID() (id, payload string, err error) { // are observability, not wire, but editing a rule shifts the recorded style-flag counts, so // a bump is a loud --resnapshot (same verdict class as ClassifierVersion). The ё-policy is // part of BriefHash (a book field), so a policy change already re-pins via brief_hash. - StyleCheckVersion string `json:"style_check_version"` - Stages []stageSnap `json:"stages"` + StyleCheckVersion string `json:"style_check_version"` + // Sanitizer — the output-sanitizer gate (D30.3). Like Coverage it does not touch the wire + // but determines a checkpoint's RESOLVED verdict (a defect flips the chunk to flagged), so a + // gate flip / rule edit is a loud --resnapshot. Folded only when enabled (sanitizerSnapshot). + Sanitizer sanitizerSnap `json:"sanitizer"` + Stages []stageSnap `json:"stages"` }{ BriefHash: r.Book.BriefHash(), ChunkerVersion: chunkerVersion, @@ -200,6 +223,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) { PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate, Coverage: r.coverageSnapshot(), StyleCheckVersion: cheapGateVersion, + Sanitizer: r.sanitizerSnapshot(), } for _, st := range r.Pipeline.Stages { ss := stageSnap{ diff --git a/backend/internal/pipeline/stagerun.go b/backend/internal/pipeline/stagerun.go index c296e7e..3489919 100644 --- a/backend/internal/pipeline/stagerun.go +++ b/backend/internal/pipeline/stagerun.go @@ -102,13 +102,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn maxRegen = 0 } + // isFinal marks the LAST stage of the chunk: the output-sanitizer (D30.3) runs only on the + // text that SHIPS, never on an intermediate draft the editor still cleans (chunkrun.go). + isFinal := stageIdx == len(r.Pipeline.Stages)-1 + var cumCost, runCost float64 var last stageAttempt anyFresh := false attemptsMade := 0 for attempt := 0; ; attempt++ { maxTokens := maxTokensForAttempt(baseMaxTokens, attempt) - att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false) + att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false, isFinal) if err != nil { return nil, err // infra failure } @@ -136,7 +140,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn // runs at most ONCE, is re-gated, and never resets the retry budget; a ceiling- // denied hop keeps the primary flag instead of aborting the book. escalated, escModel := false, "" - esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last) + esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal) if err != nil { return nil, err } @@ -213,7 +217,7 @@ type stageAttempt struct { // checkpoints — no re-billing), otherwise a fresh reserve → call → settle+ // checkpoint. It returns an error only on an infra failure; a bad completion // comes back as a classification on the attempt. -func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation bool) (stageAttempt, error) { +func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) { reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model, st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs) att := stageAttempt{reqHash: reqHash, modelActual: model} @@ -235,7 +239,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID att.finish = cp.FinishReason att.modelActual = cp.ModelActual att.cumCost = cp.CostUSD - att.cls = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason) + att.cls = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason, isFinal) rl := r.baseRequestLog(st, ch, model, reqHash) rl.ModelActual = cp.ModelActual rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls) @@ -401,7 +405,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID // non-empty truncated length draft is now classified (flagged/retried), never // silently passed downstream as OK; an empty completion is flagged too, not a // run-crash. - att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason) + att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason, isFinal) rl := r.baseRequestLog(st, ch, model, reqHash) rl.ModelActual = modelActual diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index 46dc787..a1bb25c 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -103,16 +103,20 @@ func flagReasonSeverity(reason string) int { return 0 case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail: return 1 - case FlagLoopDegenerate: + case FlagSanitizerDefect: + // A contaminated output (leaked preamble / notes / ###) is unreadable-as-shipped — + // ranked with the deterministic content failures, above a mere budget symptom. return 2 - case FlagDecodeError: + case FlagLoopDegenerate: return 3 - case FlagGlossaryMiss: + case FlagDecodeError: return 4 - case FlagLength, FlagEmpty: + case FlagGlossaryMiss: return 5 + case FlagLength, FlagEmpty: + return 6 } - return 6 + return 7 } // bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M diff --git a/backend/internal/pipeline/testdata/golden/capture.golden b/backend/internal/pipeline/testdata/golden/capture.golden index baa555b..bf189eb 100644 --- a/backend/internal/pipeline/testdata/golden/capture.golden +++ b/backend/internal/pipeline/testdata/golden/capture.golden @@ -1,58 +1,76 @@ ==== run 1 (fresh) ==== -snapshot_id: d116cc44198e42ceca936d40c198dac323ac6a412382b33f91974c535f7a198f -snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"88ec80c0cbeadd2e949061a14937f5e9ef2be7535d91b57dd0bb52ef7b57b219","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}}}]} +snapshot_id: c3d6d1f3768cf42a3e0b175e0280ff9b242c5596271a49e6fbe9e04e01dc345c +snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"cee708b1c530789a0d07f88902051fe0a9af49ad3b0706b230dbe6d503fc3cd9","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v1"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df -memory_version: 88ec80c0cbeadd2e949061a14937f5e9ef2be7535d91b57dd0bb52ef7b57b219 +memory_version: cee708b1c530789a0d07f88902051fe0a9af49ad3b0706b230dbe6d503fc3cd9 -- book result -- -chunks=5 flagged=1 exit=2 total_usd=0.0273 -chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 3cb3cfdaa396. Судзуки шёл по коридорам Академии магии." cost=0.00364 +chunks=7 flagged=2 exit=2 total_usd=0.03458 +chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 68492f45a22f. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 3cb3cfdaa396. Судзуки шёл по коридорам Академии магии." -chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД dc504dc89692. Судзуки шёл по коридорам Академии магии." cost=0.00364 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." +chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 3d50fac41818. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД dc504dc89692. Судзуки шёл по коридорам Академии магии." -chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД af3aaa22c69a. Судзуки шёл по коридорам Академии магии." cost=0.0091 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." +chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." cost=0.0091 stage=draft role=translator model=fake-fallback resume=false disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail="" - stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД d11731edd2c6. Судзуки вынес Драконью печать из Академии магии." + stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД af3aaa22c69a. Судзуки шёл по коридорам Академии магии." + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0.00728 stage=draft role=translator model=fake-model resume=false disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="refusal" cum_usd=0.00728 detail="provider finish_reason=refusal" stage_text="" stage=edit role=editor model=fake-model resume=false disp=skipped flag="hard_refusal" attempts=0 escalated=false esc_model="" finish="" cum_usd=0 detail="skipped: an upstream stage was flagged (hard_refusal)" stage_text="" -chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 8ee5af12d11e. Судзуки шёл по коридорам Академии магии." cost=0.00364 +chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 51bcc5d7929c. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 8ee5af12d11e. Судзуки шёл по коридорам Академии магии." + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." +chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." cost=0.00364 + stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." + stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." +chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0.00364 + stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." + stage=edit role=editor model=fake-model resume=false disp=flagged flag="sanitizer_defect" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="ведущая служебная преамбула: Вот перевод фрагмента:" + stage_text="" -- chunk_status -- -ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=e1e7efd9b555d6a7c8c2df7038d70752d0c69bc7a1c2176d9c4f4529286e909c cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=e3546926700784d49e919281b29ad4b89aced5c5aa6cee3cd83c879a29cf4bcc disp=ok flag="" attempts=1 final_hash=793792272f025df059c69bde97c3f6f114201e737e216ea3623cf7be676830ff cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=5f84bc5ee9b8cf49f957d01f82beeb91207149a3985d43bf03726f85eb49ff63 cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 edit snap_match=true content_hash=d019c92747ecad3c27eeeb6157b0ab27b40461621420f5e87f3c1c2c62ee5f00 disp=ok flag="" attempts=1 final_hash=354a8f6d79b274712ced45413aa1ebf1277e5e772ede9896d5e08877f8ec2dcd cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=dc7b3c0ab4a4c020560d7a7285473f960c094348dd859395a11d0f8ba4df6bf2 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=3b6bafc336a57b8b606655c17d6b588e14e76a5ac4377c770aaddbb196ea04c6 disp=ok flag="" attempts=1 final_hash=d397e96de1541500f29a25d8446b8fa93c8feae6544ed2ac778b5600573d5c64 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=5c0cb811ea30bf1e05c9ebbb9d7bf018f35c8fb39eed24aaa67f3bc15b8b72a3 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=a559165b4d1fc3f9e75472ca654f4d039ae1e0cad1541c043e7aa68fbea5415a disp=ok flag="" attempts=1 final_hash=2a732c0a6fe783fe04dbd2aa701c4b0516c92fa2c40702d18c89a5ae72e2f923 cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=d5d4d6fcfdc6b7be88110b1fbb162faac8857d0617b028e7c126f8f231859f0c cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 edit snap_match=true content_hash=b11a1740e5af98af20e207672555da8d411311e6c1e26595b456b656e8de59ef disp=ok flag="" attempts=1 final_hash=e9985b9e4a97521e94a7dd99c3676fad5149455523d5d0e31e9537d23848106b cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=347c7e5fc23c926ad157aa8cceabf862adfa33c24ceda9c5f6703ec70e8e720a cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=b467337f05775296985a6d79454b518302812edb57d398001b8852d0663c5bbb disp=ok flag="" attempts=1 final_hash=a1df2ff560f41e5c72e1f23e1a00f23011cca2e5381868db9ac0af98f4bd294b cost=0.00182 escalated=false esc_model="" detail="" ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: an upstream stage was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=05a74b9dfa02c0388fec114b849abcf598f4b5f8f74eedfe3a1536de773937f5 cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=4810bc479c959ffeadc47376a3d1eb0189d16e27fa4bfc420b836480d5be8d11 disp=ok flag="" attempts=1 final_hash=27816dc0f2e96d33803785767330c3e57cdb1d94da317d778b7ddf86203c988b cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=cc910a514d60f6f02e6d31ab9f07777e11fe0009874edff9b0c65e5cabb68311 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=708b7d426e2f9464b4c32741e4367781ddbb89a6bf119ac08b1d77c71627c7fc cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=0c1a34b813466396a5a9e8150200829fe4ea6cfec8eef999761194ae1ef6ba89 cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=99ebb10d754a2f3ae3a00d62734ad1c39160f083350217eda5bda6e91373f4de disp=ok flag="" attempts=1 final_hash=9d13216d51b230a9519b5afe640b83c25c2a3eb31f45efaa6889aef3acee2e09 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=6746c63437db911f9b3588d4ddcf8d6effb9f5131df8c7c4ce65cae2236c9534 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 edit snap_match=true content_hash=f9a70ce8a43db4a89eda5e53a6f29ba5f0c2a35559a9d35b73f771220bb262ee disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=e1e7efd9b555d6a7c8c2df7038d70752d0c69bc7a1c2176d9c4f4529286e909c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=793792272f025df059c69bde97c3f6f114201e737e216ea3623cf7be676830ff tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=5f84bc5ee9b8cf49f957d01f82beeb91207149a3985d43bf03726f85eb49ff63 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 edit role=editor req=fake-model actual=fake-model hash=354a8f6d79b274712ced45413aa1ebf1277e5e772ede9896d5e08877f8ec2dcd tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=b7778749a911bd7e14a5805e217bdc1f263721767cb276db97aca82048137f38 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=dc7b3c0ab4a4c020560d7a7285473f960c094348dd859395a11d0f8ba4df6bf2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=d397e96de1541500f29a25d8446b8fa93c8feae6544ed2ac778b5600573d5c64 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=be3017a19fb9ffad41441feee0c69da27b517e8ee67dc5fc0ecd1ab9c0ce888e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=f68fd85bc7a062c70587b595806290ebeff13559b5d80a06fc0cdee6a740e525 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=05a74b9dfa02c0388fec114b849abcf598f4b5f8f74eedfe3a1536de773937f5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=27816dc0f2e96d33803785767330c3e57cdb1d94da317d778b7ddf86203c988b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=5c0cb811ea30bf1e05c9ebbb9d7bf018f35c8fb39eed24aaa67f3bc15b8b72a3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=2a732c0a6fe783fe04dbd2aa701c4b0516c92fa2c40702d18c89a5ae72e2f923 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=d5d4d6fcfdc6b7be88110b1fbb162faac8857d0617b028e7c126f8f231859f0c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 edit role=editor req=fake-model actual=fake-model hash=e9985b9e4a97521e94a7dd99c3676fad5149455523d5d0e31e9537d23848106b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=e9aa356e2553b0b678946bbd418f48b2bf5fb58d403f0512b4b5baa92e7ad34f tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=347c7e5fc23c926ad157aa8cceabf862adfa33c24ceda9c5f6703ec70e8e720a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=a1df2ff560f41e5c72e1f23e1a00f23011cca2e5381868db9ac0af98f4bd294b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=68d833174eaa07b859b1e8fd7bf90f95c408f61b76b6684dbbb31402895d40ac tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=9874cb22d4d68774551865886f2d186b156d138d791c4368deed09c2bf71e21b tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=cc910a514d60f6f02e6d31ab9f07777e11fe0009874edff9b0c65e5cabb68311 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=708b7d426e2f9464b4c32741e4367781ddbb89a6bf119ac08b1d77c71627c7fc tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=0c1a34b813466396a5a9e8150200829fe4ea6cfec8eef999761194ae1ef6ba89 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=9d13216d51b230a9519b5afe640b83c25c2a3eb31f45efaa6889aef3acee2e09 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=6746c63437db911f9b3588d4ddcf8d6effb9f5131df8c7c4ce65cae2236c9534 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=e9f657977bca5176035af240b83f06852c8e8ba0a51adab8aa915abea9999b02 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= @@ -64,82 +82,112 @@ ch3/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= ch4/0 snap_match=true exact=0 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 injected_ids=[] postcheck_detail= style_detail= +ch5/0 snap_match=true exact=2 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 + injected_ids=["老人\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= +ch6/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 + injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= -- wire bodies (call order) -- -[0] {"max_tokens":2954,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n紋章 → герб\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第一章 図書館の秘密\n\n一番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ一層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n二番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ二層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n三番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ三層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n四番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ四層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n五番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ五層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n六番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ六層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。扉の奥で彼が見たものは、台座に置かれた竜の紋章だった。それは禁じられた歴史の最後の欠片だと、後に彼は知ることになる。\n\n七番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ七層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} -[1] {"max_tokens":512,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «герб»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 68492f45a22f. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} -[2] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки\n魔法学院 → Академия магии\n紋章 → герб"},{"role":"user","content":"八番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ八層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。"}],"model":"fake-model","stream":false,"temperature":0.3} -[3] {"max_tokens":512,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»\n- «Академия магии»\n- «герб»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 3d50fac41818. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} -[4] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-model","stream":false,"temperature":0.3} -[5] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} -[6] {"max_tokens":512,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Драконья печать»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД d11731edd2c6. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} -[7] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-model","stream":false,"temperature":0.3} -[8] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} -[9] {"max_tokens":512,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"user","content":"第四章 静かな終わり\n\n夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。\n\n遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。"}],"model":"fake-model","stream":false,"temperature":0.3} -[10] {"max_tokens":512,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 51bcc5d7929c. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[0] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n紋章 → герб\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第一章 図書館の秘密\n\n一番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ一層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n二番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ二層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n三番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ三層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n四番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ四層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n五番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ五層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n六番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ六層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。扉の奥で彼が見たものは、台座に置かれた竜の紋章だった。それは禁じられた歴史の最後の欠片だと、後に彼は知ることになる。\n\n七番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ七層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} +[1] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «герб»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[2] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки\n魔法学院 → Академия магии\n紋章 → герб"},{"role":"user","content":"八番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ八層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。"}],"model":"fake-model","stream":false,"temperature":0.3} +[3] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»\n- «Академия магии»\n- «герб»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[4] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-model","stream":false,"temperature":0.3} +[5] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} +[6] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Драконья печать»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[7] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-model","stream":false,"temperature":0.3} +[8] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} +[9] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"user","content":"第四章 静かな終わり\n\n夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。\n\n遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。"}],"model":"fake-model","stream":false,"temperature":0.3} +[10] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[11] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n老人 → старик\n鈴木 → Судзуки"},{"role":"user","content":"第五章 老人の秘密\n\n鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。"}],"model":"fake-model","stream":false,"temperature":0.3} +[12] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «старик»\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика."}],"model":"fake-model","stream":false,"temperature":0.4} +[13] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第六章 序文漏洩\n\n鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} +[14] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь."}],"model":"fake-model","stream":false,"temperature":0.4} ==== run 2 (resume) ==== -snapshot_id: d116cc44198e42ceca936d40c198dac323ac6a412382b33f91974c535f7a198f -snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"88ec80c0cbeadd2e949061a14937f5e9ef2be7535d91b57dd0bb52ef7b57b219","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null}}}]} +snapshot_id: c3d6d1f3768cf42a3e0b175e0280ff9b242c5596271a49e6fbe9e04e01dc345c +snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"cee708b1c530789a0d07f88902051fe0a9af49ad3b0706b230dbe6d503fc3cd9","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v1"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df -memory_version: 88ec80c0cbeadd2e949061a14937f5e9ef2be7535d91b57dd0bb52ef7b57b219 +memory_version: cee708b1c530789a0d07f88902051fe0a9af49ad3b0706b230dbe6d503fc3cd9 -- book result -- -chunks=5 flagged=1 exit=2 total_usd=0 -chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 3cb3cfdaa396. Судзуки шёл по коридорам Академии магии." cost=0 +chunks=7 flagged=2 exit=2 total_usd=0 +chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 68492f45a22f. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 3cb3cfdaa396. Судзуки шёл по коридорам Академии магии." -chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД dc504dc89692. Судзуки шёл по коридорам Академии магии." cost=0 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." +chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 3d50fac41818. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД dc504dc89692. Судзуки шёл по коридорам Академии магии." -chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД af3aaa22c69a. Судзуки шёл по коридорам Академии магии." cost=0 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." +chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-fallback resume=true disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail="" - stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД d11731edd2c6. Судзуки вынес Драконью печать из Академии магии." + stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД af3aaa22c69a. Судзуки шёл по коридорам Академии магии." + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0 stage=draft role=translator model=fake-model resume=true disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="" cum_usd=0.00728 detail="provider finish_reason=refusal" stage_text="" stage=edit role=editor model=fake-model resume=false disp=skipped flag="hard_refusal" attempts=0 escalated=false esc_model="" finish="" cum_usd=0 detail="skipped: an upstream stage was flagged (hard_refusal)" stage_text="" -chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 8ee5af12d11e. Судзуки шёл по коридорам Академии магии." cost=0 +chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 51bcc5d7929c. Судзуки шёл по коридорам Академии магии." + stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 8ee5af12d11e. Судзуки шёл по коридорам Академии магии." + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." +chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." cost=0 + stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." + stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." +chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0 + stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" + stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." + stage=edit role=editor model=fake-model resume=true disp=flagged flag="sanitizer_defect" attempts=1 escalated=false esc_model="" finish="" cum_usd=0.00182 detail="ведущая служебная преамбула: Вот перевод фрагмента:" + stage_text="" -- chunk_status -- -ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=e1e7efd9b555d6a7c8c2df7038d70752d0c69bc7a1c2176d9c4f4529286e909c cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=e3546926700784d49e919281b29ad4b89aced5c5aa6cee3cd83c879a29cf4bcc disp=ok flag="" attempts=1 final_hash=793792272f025df059c69bde97c3f6f114201e737e216ea3623cf7be676830ff cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=5f84bc5ee9b8cf49f957d01f82beeb91207149a3985d43bf03726f85eb49ff63 cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 edit snap_match=true content_hash=d019c92747ecad3c27eeeb6157b0ab27b40461621420f5e87f3c1c2c62ee5f00 disp=ok flag="" attempts=1 final_hash=354a8f6d79b274712ced45413aa1ebf1277e5e772ede9896d5e08877f8ec2dcd cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=dc7b3c0ab4a4c020560d7a7285473f960c094348dd859395a11d0f8ba4df6bf2 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=3b6bafc336a57b8b606655c17d6b588e14e76a5ac4377c770aaddbb196ea04c6 disp=ok flag="" attempts=1 final_hash=d397e96de1541500f29a25d8446b8fa93c8feae6544ed2ac778b5600573d5c64 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=5c0cb811ea30bf1e05c9ebbb9d7bf018f35c8fb39eed24aaa67f3bc15b8b72a3 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=a559165b4d1fc3f9e75472ca654f4d039ae1e0cad1541c043e7aa68fbea5415a disp=ok flag="" attempts=1 final_hash=2a732c0a6fe783fe04dbd2aa701c4b0516c92fa2c40702d18c89a5ae72e2f923 cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=d5d4d6fcfdc6b7be88110b1fbb162faac8857d0617b028e7c126f8f231859f0c cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 edit snap_match=true content_hash=b11a1740e5af98af20e207672555da8d411311e6c1e26595b456b656e8de59ef disp=ok flag="" attempts=1 final_hash=e9985b9e4a97521e94a7dd99c3676fad5149455523d5d0e31e9537d23848106b cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=347c7e5fc23c926ad157aa8cceabf862adfa33c24ceda9c5f6703ec70e8e720a cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=b467337f05775296985a6d79454b518302812edb57d398001b8852d0663c5bbb disp=ok flag="" attempts=1 final_hash=a1df2ff560f41e5c72e1f23e1a00f23011cca2e5381868db9ac0af98f4bd294b cost=0.00182 escalated=false esc_model="" detail="" ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: an upstream stage was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=05a74b9dfa02c0388fec114b849abcf598f4b5f8f74eedfe3a1536de773937f5 cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=4810bc479c959ffeadc47376a3d1eb0189d16e27fa4bfc420b836480d5be8d11 disp=ok flag="" attempts=1 final_hash=27816dc0f2e96d33803785767330c3e57cdb1d94da317d778b7ddf86203c988b cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=cc910a514d60f6f02e6d31ab9f07777e11fe0009874edff9b0c65e5cabb68311 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=708b7d426e2f9464b4c32741e4367781ddbb89a6bf119ac08b1d77c71627c7fc cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=0c1a34b813466396a5a9e8150200829fe4ea6cfec8eef999761194ae1ef6ba89 cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=99ebb10d754a2f3ae3a00d62734ad1c39160f083350217eda5bda6e91373f4de disp=ok flag="" attempts=1 final_hash=9d13216d51b230a9519b5afe640b83c25c2a3eb31f45efaa6889aef3acee2e09 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=6746c63437db911f9b3588d4ddcf8d6effb9f5131df8c7c4ce65cae2236c9534 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 edit snap_match=true content_hash=f9a70ce8a43db4a89eda5e53a6f29ba5f0c2a35559a9d35b73f771220bb262ee disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=e1e7efd9b555d6a7c8c2df7038d70752d0c69bc7a1c2176d9c4f4529286e909c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=793792272f025df059c69bde97c3f6f114201e737e216ea3623cf7be676830ff tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=5f84bc5ee9b8cf49f957d01f82beeb91207149a3985d43bf03726f85eb49ff63 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 edit role=editor req=fake-model actual=fake-model hash=354a8f6d79b274712ced45413aa1ebf1277e5e772ede9896d5e08877f8ec2dcd tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=b7778749a911bd7e14a5805e217bdc1f263721767cb276db97aca82048137f38 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=dc7b3c0ab4a4c020560d7a7285473f960c094348dd859395a11d0f8ba4df6bf2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=d397e96de1541500f29a25d8446b8fa93c8feae6544ed2ac778b5600573d5c64 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=be3017a19fb9ffad41441feee0c69da27b517e8ee67dc5fc0ecd1ab9c0ce888e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=f68fd85bc7a062c70587b595806290ebeff13559b5d80a06fc0cdee6a740e525 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=05a74b9dfa02c0388fec114b849abcf598f4b5f8f74eedfe3a1536de773937f5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=27816dc0f2e96d33803785767330c3e57cdb1d94da317d778b7ddf86203c988b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 draft role=translator req=fake-model actual=fake-model hash=e1e7efd9b555d6a7c8c2df7038d70752d0c69bc7a1c2176d9c4f4529286e909c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=793792272f025df059c69bde97c3f6f114201e737e216ea3623cf7be676830ff tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=5f84bc5ee9b8cf49f957d01f82beeb91207149a3985d43bf03726f85eb49ff63 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/1 edit role=editor req=fake-model actual=fake-model hash=354a8f6d79b274712ced45413aa1ebf1277e5e772ede9896d5e08877f8ec2dcd tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=dc7b3c0ab4a4c020560d7a7285473f960c094348dd859395a11d0f8ba4df6bf2 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=d397e96de1541500f29a25d8446b8fa93c8feae6544ed2ac778b5600573d5c64 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=5c0cb811ea30bf1e05c9ebbb9d7bf018f35c8fb39eed24aaa67f3bc15b8b72a3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=2a732c0a6fe783fe04dbd2aa701c4b0516c92fa2c40702d18c89a5ae72e2f923 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=d5d4d6fcfdc6b7be88110b1fbb162faac8857d0617b028e7c126f8f231859f0c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 edit role=editor req=fake-model actual=fake-model hash=e9985b9e4a97521e94a7dd99c3676fad5149455523d5d0e31e9537d23848106b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=e9aa356e2553b0b678946bbd418f48b2bf5fb58d403f0512b4b5baa92e7ad34f tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=347c7e5fc23c926ad157aa8cceabf862adfa33c24ceda9c5f6703ec70e8e720a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=a1df2ff560f41e5c72e1f23e1a00f23011cca2e5381868db9ac0af98f4bd294b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=68d833174eaa07b859b1e8fd7bf90f95c408f61b76b6684dbbb31402895d40ac tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=9874cb22d4d68774551865886f2d186b156d138d791c4368deed09c2bf71e21b tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=cc910a514d60f6f02e6d31ab9f07777e11fe0009874edff9b0c65e5cabb68311 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=708b7d426e2f9464b4c32741e4367781ddbb89a6bf119ac08b1d77c71627c7fc tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=0c1a34b813466396a5a9e8150200829fe4ea6cfec8eef999761194ae1ef6ba89 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=9d13216d51b230a9519b5afe640b83c25c2a3eb31f45efaa6889aef3acee2e09 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=6746c63437db911f9b3588d4ddcf8d6effb9f5131df8c7c4ce65cae2236c9534 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=e9f657977bca5176035af240b83f06852c8e8ba0a51adab8aa915abea9999b02 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=5c0cb811ea30bf1e05c9ebbb9d7bf018f35c8fb39eed24aaa67f3bc15b8b72a3 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=2a732c0a6fe783fe04dbd2aa701c4b0516c92fa2c40702d18c89a5ae72e2f923 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=d5d4d6fcfdc6b7be88110b1fbb162faac8857d0617b028e7c126f8f231859f0c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/1 edit role=editor req=fake-model actual=fake-model hash=e9985b9e4a97521e94a7dd99c3676fad5149455523d5d0e31e9537d23848106b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=347c7e5fc23c926ad157aa8cceabf862adfa33c24ceda9c5f6703ec70e8e720a tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=a1df2ff560f41e5c72e1f23e1a00f23011cca2e5381868db9ac0af98f4bd294b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" ch3/0 draft role=translator req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="hard_refusal" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=05a74b9dfa02c0388fec114b849abcf598f4b5f8f74eedfe3a1536de773937f5 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=27816dc0f2e96d33803785767330c3e57cdb1d94da317d778b7ddf86203c988b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=cc910a514d60f6f02e6d31ab9f07777e11fe0009874edff9b0c65e5cabb68311 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=708b7d426e2f9464b4c32741e4367781ddbb89a6bf119ac08b1d77c71627c7fc tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=0c1a34b813466396a5a9e8150200829fe4ea6cfec8eef999761194ae1ef6ba89 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=9d13216d51b230a9519b5afe640b83c25c2a3eb31f45efaa6889aef3acee2e09 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=6746c63437db911f9b3588d4ddcf8d6effb9f5131df8c7c4ce65cae2236c9534 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="sanitizer_defect" cost=0 tokens=0/0/0/0/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= @@ -151,4 +199,8 @@ ch3/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= ch4/0 snap_match=true exact=0 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 injected_ids=[] postcheck_detail= style_detail= +ch5/0 snap_match=true exact=2 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 + injected_ids=["老人\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= +ch6/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 + injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= -- wire bodies (call order) -- diff --git a/backend/internal/pipeline/testdata/golden/glossary-seed.yaml b/backend/internal/pipeline/testdata/golden/glossary-seed.yaml index 22bfa28..9d44560 100644 --- a/backend/internal/pipeline/testdata/golden/glossary-seed.yaml +++ b/backend/internal/pipeline/testdata/golden/glossary-seed.yaml @@ -1,11 +1,20 @@ -# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2) -# and a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path. +# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2), +# a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path, and (D28.2 fixture +# expansion) an OBLIQUE-ONLY decl entry (老人→старик — decl lists only genitive/dative/…, NO +# nominative) that fires in ch5 whose FINAL text carries the NOMINATIVE «старик»: it pins the +# base-dst∪decl union (D24.3/D24.4) — reverting the union false-flags it as a postcheck miss. terms: - src: 鈴木 dst: Судзуки type: name gender: m decl: { invariant: true } + - src: 老人 + dst: старик + type: term + decl: + # Oblique-only ON PURPOSE (no «старик» nominative) — the union check must supply the base. + forms: [старика, старику, стариком, старике] - src: 魔法学院 dst: Академия магии type: org diff --git a/backend/internal/pipeline/testdata/golden/pipeline.yaml b/backend/internal/pipeline/testdata/golden/pipeline.yaml index 26adc8f..c1d35b6 100644 --- a/backend/internal/pipeline/testdata/golden/pipeline.yaml +++ b/backend/internal/pipeline/testdata/golden/pipeline.yaml @@ -1,9 +1,11 @@ -# Golden-fixture pipeline: C1 draft→edit with a single-hop escalation on the draft. +# Golden-fixture pipeline: C1 draft→edit with a single-hop escalation on the draft and the +# output-sanitizer gate ON (D28.2 fixture expansion — pins the FlagSanitizerDefect path in ch6). core: C1 version: 1 defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } retries: { regenerate_before_escalate: 1 } context: { glossary_injection: selective, glossary_token_budget: 800 } +gates: { sanitizer: { enabled: true } } escalation: { budget_usd: 0.5 } stages: - { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-golden, temperature: 0.3, reasoning: "off", escalate_to: fake-fallback } diff --git a/backend/internal/pipeline/testdata/golden/source.txt b/backend/internal/pipeline/testdata/golden/source.txt index 190fa3e..9b5fa4d 100644 --- a/backend/internal/pipeline/testdata/golden/source.txt +++ b/backend/internal/pipeline/testdata/golden/source.txt @@ -33,3 +33,11 @@ 夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。 遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。 + +第五章 老人の秘密 + +鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。 + +第六章 序文漏洩 + +鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。 diff --git a/backend/prompts/editor-mono.md b/backend/prompts/editor-mono.md new file mode 100644 index 0000000..f32de82 --- /dev/null +++ b/backend/prompts/editor-mono.md @@ -0,0 +1,16 @@ +Ты — монолингвальный литературный редактор перевода на русский язык. Ты видишь ТОЛЬКО черновик перевода, без исходного текста. +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Твой мандат УЗКИЙ — художественная редактура черновика перевода: +- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским. +- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. +- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. +- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку черновика. Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет. +- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. + +Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. + +---USER--- +Черновик перевода для редактуры: + +{{draft}} diff --git a/backend/prompts/editor.md b/backend/prompts/editor.md index 7540343..63f9601 100644 --- a/backend/prompts/editor.md +++ b/backend/prompts/editor.md @@ -1,16 +1,21 @@ -Ты — литературный редактор художественного перевода на русский язык. +Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод. Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». -Твой мандат УЗКИЙ — художественная редактура черновика перевода: -- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции. -- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. +Твой мандат — художественная редактура черновика со сверкой по исходнику: +- Сверяй смысл черновика с исходным текстом (язык «{{source_lang}}»): исправляй искажения смысла, неверно понятые реалии, кальки жестов и идиом — переводчик мог передать выражение буквально (напр. поклон как «ударил головой»). Опирайся на исходник, но пиши по-русски, а не подстрочником. +- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским. - Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. -- Сохраняй разбивку на абзацы. +- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет. +- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. - Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. -Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений. +Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. ---USER--- +Исходный текст (язык «{{source_lang}}»): + +{{text}} + Черновик перевода для редактуры: {{draft}} diff --git a/backend/prompts/translator.md b/backend/prompts/translator.md index 84bdc06..0074106 100644 --- a/backend/prompts/translator.md +++ b/backend/prompts/translator.md @@ -6,10 +6,10 @@ - Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. - Сноски: {{footnotes}}. - Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя. -- Сохраняй разбивку на абзацы и прямую речь как в оригинале. +- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—». - Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. -Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков. +Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков. ---USER--- Переведи следующий фрагмент: