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
This commit is contained in:
parent
7487b782f8
commit
00f8e36fbd
24 changed files with 1101 additions and 200 deletions
|
|
@ -55,10 +55,11 @@ providers:
|
||||||
reasoning: subset
|
reasoning: subset
|
||||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||||
|
|
||||||
xai: # Grok — центральная модель Фазы 1: дефолт-редактор SFW + главный тир канала B + судья 18+.
|
xai: # Grok — главный тир канала B (18+) + судья 18+; из редакторских ролей СНЯТ (D30.1 — reasoning-off no-op).
|
||||||
# ⚠️ ГИГИЕНА АККАУНТОВ (D8/оркестратор): XAI_API_KEY обязан быть ЧИСТЫМ прод-аккаунтом
|
# ⚠️ КЛЮЧЕВАЯ ПОЛИТИКА (D27, супрсидит «чистый ключ» D19.4/D20.3): ЕДИНЫЙ XAI_API_KEY С data-sharing
|
||||||
# БЕЗ data-sharing. Промо-$150-аккаунт (data-sharing) — ТОЛЬКО eval/NSFW, реальные книги
|
# (промо) идёт на ВСЕ тесты И пилот И прод; data-sharing ОТКЛЮЧАЕТСЯ перед релизом (сверить механику
|
||||||
# через него гнать нельзя. По ключу это не проверяется — подтвердить перед боевым прогоном.
|
# с вендор-докой — чек-лист первого боевого прогона). Риски приняты владельцем (копирайт 蛊真人 в обучение —
|
||||||
|
# только свои тесты, для клиентских книг НИКОГДА; NSFW-история аккаунта). По ключу не проверяется.
|
||||||
kind: openai
|
kind: openai
|
||||||
base_url: https://api.x.ai/v1
|
base_url: https://api.x.ai/v1
|
||||||
api_key_env: XAI_API_KEY
|
api_key_env: XAI_API_KEY
|
||||||
|
|
@ -73,9 +74,10 @@ providers:
|
||||||
# Gemini Фазы 2 (см. provider_anthropic.go). Эскалация — не-Anthropic (Kimi/GLM).
|
# Gemini Фазы 2 (см. provider_anthropic.go). Эскалация — не-Anthropic (Kimi/GLM).
|
||||||
|
|
||||||
gemini: # Google Gemini через OpenAI-совместимый слой (base .../v1beta/openai). Апекс канала A +
|
gemini: # Google Gemini через OpenAI-совместимый слой (base .../v1beta/openai). Апекс канала A +
|
||||||
# судья Фазы 2. ⚠️ Нативный Gemini API нужен для safety-off судьи 18+ (safety_settings не
|
# судья violence/SFW Ф2 + премиум-эскалация редактора ЗА санитайзером (D30.1/30.3 — течёт преамбулой 6/6).
|
||||||
# проходят через OpenAI-слой — exp00); OpenAI-слой достаточен для перевода/эскалации (фильтры OFF
|
# ⚠️ Как судья 18+ ЭРОТИКИ НЕПРИГОДЕН (D22.6/exp11: 8× PROHIBITED_CONTENT, фильтр неконфигурируем — native
|
||||||
# по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9).
|
# НЕ спасает; первичный судья эротики = Grok). На violence/SFW Gemini-судья остаётся. OpenAI-слой достаточен
|
||||||
|
# для перевода/эскалации (фильтры OFF по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9).
|
||||||
kind: openai
|
kind: openai
|
||||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
api_key_env: GEMINI_API_KEY
|
api_key_env: GEMINI_API_KEY
|
||||||
|
|
@ -187,9 +189,10 @@ models:
|
||||||
temperature: { mode: send }
|
temperature: { mode: send }
|
||||||
reasoning: { control: effort, off_effort: none }
|
reasoning: { control: effort, off_effort: none }
|
||||||
note: >-
|
note: >-
|
||||||
Дефолт-редактор SFW (D3, bake-off эксп-04) + главный пермиссив-тир канала B + судья 18+.
|
Главный пермиссив-тир канала B + судья 18+ ЭРОТИКИ (первичный для НЕ-grok переводов — D22.6: Gemini
|
||||||
reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3). Включена
|
fail-closed на графичной эротике, native не спасает). Из редакторов СНЯТ (D30.1 — reasoning-off no-op;
|
||||||
в edit-стадию C1/C2 (D1/D3, консолидация 05.07); прод-прогон — только с ЧИСТЫМ xAI-ключом без data-sharing.
|
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)
|
gemini-3.1-pro-preview: # ⚠️ слаг ИМЕННО -preview: gemini-3.1-pro → HTTP 404 NOT_FOUND (live 2026-07-10)
|
||||||
provider: gemini
|
provider: gemini
|
||||||
|
|
@ -204,8 +207,9 @@ models:
|
||||||
# Раньше «дать max_tokens ≥8000» — комментарий; теперь схема (thinking ⊆ бюджета, additive_total).
|
# Раньше «дать max_tokens ≥8000» — комментарий; теперь схема (thinking ⊆ бюджета, additive_total).
|
||||||
min_max_tokens: 8000
|
min_max_tokens: 8000
|
||||||
note: >-
|
note: >-
|
||||||
Апекс канала A / судья Фазы 2 (D3). min_max_tokens 8000 держит thinking внутри бюджета (резерв не слепнет; settle
|
Апекс канала A / судья violence/SFW Ф2 / премиум-эскалация редактора за санитайзером (D30.1). min_max_tokens 8000
|
||||||
биллит thinking через additive_total). Судья 18+ требует НАТИВНОГО Gemini (safety-off) — Ф2. Живьём 2026-07-10: chat 200.
|
держит thinking внутри бюджета (резерв не слепнет; settle биллит thinking через additive_total). Как судья ЭРОТИКИ
|
||||||
|
непригоден (D22.6: PROHIBITED_CONTENT неконфигурируем, native не спасает — первичный судья эротики Grok). Живьём 2026-07-10: chat 200.
|
||||||
|
|
||||||
mistral-large-2512:
|
mistral-large-2512:
|
||||||
provider: mistral
|
provider: mistral
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,12 @@ retries:
|
||||||
stages:
|
stages:
|
||||||
- name: draft
|
- name: draft
|
||||||
role: translator
|
role: translator
|
||||||
model: deepseek-v4-flash
|
model: deepseek-v4-flash # интерим — переводчик по exp13 (draft качеством не мерен, D30.6); флип при развязке бейк-оффа
|
||||||
prompt: ../prompts/translator.md
|
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
|
temperature: 0.3
|
||||||
reasoning: "off"
|
reasoning: "off"
|
||||||
# single-hop echo-эскалация (D18: deepseek-эхо 25% на 蛊真人 → фолбэк выстрелит). deepseek-v4-pro —
|
# single-hop echo-эскалация (D18: deepseek-эхо 25% на 蛊真人 → фолбэк выстрелит). deepseek-v4-pro —
|
||||||
|
|
@ -45,15 +48,18 @@ stages:
|
||||||
escalate_to: deepseek-v4-pro
|
escalate_to: deepseek-v4-pro
|
||||||
- name: edit
|
- name: edit
|
||||||
role: editor
|
role: editor
|
||||||
# Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером).
|
# Дефолт-редактор — glm-5 БИЛИНГВ (флип D1→D30.1, supersede D17.в): моно-редактор
|
||||||
# reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none,
|
# пассивен и структурно слеп к искажениям черновика (exp12 §2.2/2.5); билингв видит
|
||||||
# models.yaml) — омиссия у grok = low. Прод: только чистый xAI-ключ без data-sharing.
|
# исходник (editor.md получил {{text}}). glm-5 — кандидат value по exp12 §3 (судейский #1,
|
||||||
model: grok-4.3
|
# верность 5.0, 0 утечек преамбул; grok reasoning-off из редакторских ролей СНЯТ — no-op).
|
||||||
|
# gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6).
|
||||||
|
model: glm-5
|
||||||
prompt: ../prompts/editor.md
|
prompt: ../prompts/editor.md
|
||||||
# D1-уточнение №3: коммит 6a68dfd сделал editor.md монолингвальным (убрал {{text}}) →
|
# Bump v1-monolingual→v2-bilingual-reflow: editor.md стал билингвальным (D30.1) + reflow
|
||||||
# сменил SHA, но лейбл остался v0-draft (один лейбл на два SHA). Бамп на v1-monolingual;
|
# (D30.2, убрана «Сохраняй разбивку на абзацы»). Оба меняют SHA промпта → осознанный
|
||||||
# осознанно меняет снапшот (боевых прогонов ещё не было).
|
# --resnapshot. Моно-вариант сохранён отдельно (prompts/editor-mono.md) — подтверждающий
|
||||||
prompt_version: v1-monolingual
|
# пилот-арм D13.1.
|
||||||
|
prompt_version: v2-bilingual-reflow
|
||||||
temperature: 0.4
|
temperature: 0.4
|
||||||
reasoning: "off"
|
reasoning: "off"
|
||||||
|
|
||||||
|
|
@ -68,6 +74,16 @@ gates:
|
||||||
en-ru: [0.70, 1.4]
|
en-ru: [0.70, 1.4]
|
||||||
sent_cov_min: 0.75
|
sent_cov_min: 0.75
|
||||||
min_chunk_chars: 500 # калибровка порогов — на фрагментах 500–2500 симв.
|
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:
|
escalation:
|
||||||
chains:
|
chains:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ stages:
|
||||||
role: translator
|
role: translator
|
||||||
model: deepseek-v4-flash
|
model: deepseek-v4-flash
|
||||||
prompt: ../prompts/translator.md
|
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)
|
temperature: 0.8 # C2: кандидаты сэмплируются горячее (Р2: T=0.6–1.0)
|
||||||
reasoning: "off"
|
reasoning: "off"
|
||||||
# Judge-селектор (пара судей, шкала Комиссарова) — Фаза 2; стадия объявлена,
|
# Judge-селектор (пара судей, шкала Комиссарова) — Фаза 2; стадия объявлена,
|
||||||
|
|
@ -40,15 +40,14 @@ stages:
|
||||||
reasoning: "off"
|
reasoning: "off"
|
||||||
- name: edit
|
- name: edit
|
||||||
role: editor
|
role: editor
|
||||||
# Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером).
|
# C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют
|
||||||
# reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none).
|
# за C1: editor.md стал БИЛИНГВ (D30.1) + reflow (D30.2) — тот же общий файл. Дефолт-редактор
|
||||||
# (Стадия select выше — judge/glm-5, Gemini-слот Фазы 2; её НЕ трогаем.)
|
# по D30.1 — glm-5 билингв (grok reasoning-off из редакторов СНЯТ — no-op). (Стадию select
|
||||||
model: grok-4.3
|
# выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.)
|
||||||
|
model: glm-5
|
||||||
prompt: ../prompts/editor.md
|
prompt: ../prompts/editor.md
|
||||||
# D1-уточнение №3: editor.md стал монолингвальным (6a68dfd убрал {{text}}) → SHA сменился,
|
# Бамп v1-monolingual→v2-bilingual-reflow (label-SHA дисциплина: тот же editor.md, что в C1).
|
||||||
# лейбл v0-draft остался (один лейбл на два SHA). Бамп на v1-monolingual (тот же editor.md,
|
prompt_version: v2-bilingual-reflow
|
||||||
# что в C1). Стадию select выше НЕ трогаем.
|
|
||||||
prompt_version: v1-monolingual
|
|
||||||
temperature: 0.4
|
temperature: 0.4
|
||||||
reasoning: "off"
|
reasoning: "off"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,44 @@
|
||||||
# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
|
# D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
|
||||||
|
|
||||||
**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
|
**Статус:** **v3.1 — реализация РАЗБЛОКИРОВАНА (D30.9, промт `BACKEND_D152_SESSION_PROMPT.md`,
|
||||||
ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в
|
этап Б).** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные
|
||||||
`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в
|
пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/`
|
||||||
|
(зона бэкенда); при лендинге оркестратор сворачивает её в
|
||||||
`docs/architecture/03-implementation-notes.md` новым разделом.
|
`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: расщепить
|
**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
|
||||||
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
|
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
|
||||||
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
|
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
|
||||||
|
|
@ -146,7 +180,7 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
|
||||||
|
|
||||||
| Поле (сегодня) | Класс | Куда в v2 | Почему |
|
| Поле (сегодня) | Класс | Куда в 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, гранулярно. |
|
| `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). |
|
| `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-у. |
|
| `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.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
|
||||||
| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. |
|
| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. |
|
||||||
| `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `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` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
|
| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
|
||||||
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
|
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
|
||||||
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
|
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
|
||||||
|
|
@ -399,6 +435,20 @@ Reuse чекпоинта легален ⟺ **полная wire-идентичн
|
||||||
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
|
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
|
||||||
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`.
|
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-ридеров
|
## 9. Судьба `chunk_status.snapshot_id` и status-ридеров
|
||||||
|
|
||||||
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
|
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
|
||||||
|
|
@ -510,16 +560,19 @@ per chunk×stage, а не рассуждает аналитически):**
|
||||||
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
|
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
|
||||||
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
|
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
|
||||||
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
|
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
|
||||||
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**)
|
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**/
|
||||||
→ $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 +
|
**sanitizer**/**source_lang·target_lang** — v3.1-a) → $0 re-classify, вердикт обновлён; (б) append-термина
|
||||||
editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/
|
→ re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/
|
||||||
capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать
|
модель/**temperature**/**reasoning**/capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**:
|
||||||
temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке
|
ослабить `guard_hash` (убрать temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на
|
||||||
(пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под
|
wire-нейтральном чанке (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` —
|
||||||
coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис)
|
re-classify под coverage (§5-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет
|
||||||
editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не
|
`RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д)
|
||||||
только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е)
|
dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а
|
||||||
миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.
|
→ проекция считает 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)
|
## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,45 +165,51 @@ func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBoevoyConfigGrokEditorExplicitNone pins the consolidation fix (tasks 1-3): grok-4.3
|
// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the
|
||||||
// resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT
|
// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and
|
||||||
// off-by-omission — grok's OMITTED default is "low" (it thinks, billed additively per xAI, so
|
// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning
|
||||||
// control:none would silently cost ~444 reasoning tokens/edit). It must not trip the echo-mine
|
// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an
|
||||||
// guard (xAI is not echo-prone: the editor input is a Russian draft), and the edit stage of both
|
// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission
|
||||||
// boevoy pipelines must be grok-4.3, while c2's select (judge) stage stays the glm-5 placeholder.
|
// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not
|
||||||
func TestBoevoyConfigGrokEditorExplicitNone(t *testing.T) {
|
// 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"))
|
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load boevoy models.yaml: %v", err)
|
t.Fatalf("load boevoy models.yaml: %v", err)
|
||||||
}
|
}
|
||||||
const model = "grok-4.3"
|
// (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip).
|
||||||
cap := m.ResolveCapability(model)
|
const grok = "grok-4.3"
|
||||||
|
cap := m.ResolveCapability(grok)
|
||||||
if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" {
|
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)",
|
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 != "" {
|
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", model, 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"} {
|
for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
|
||||||
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
|
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load %s: %v", pf, err)
|
t.Fatalf("load %s: %v", pf, err)
|
||||||
}
|
}
|
||||||
var editModel, selectModel string
|
var editModel, editReasoning, selectModel string
|
||||||
for _, st := range p.Stages {
|
for _, st := range p.Stages {
|
||||||
switch st.Role {
|
switch st.Role {
|
||||||
case "editor":
|
case "editor":
|
||||||
editModel = st.Model
|
editModel, editReasoning = st.Model, st.Reasoning
|
||||||
case "judge":
|
case "judge":
|
||||||
selectModel = st.Model
|
selectModel = st.Model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if editModel != "grok-4.3" {
|
if editModel != "glm-5" {
|
||||||
t.Errorf("%s edit stage model = %q, want grok-4.3 (D1/D3 default; glm-5 was the placeholder)", pf, editModel)
|
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" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,24 @@ type Stage struct {
|
||||||
|
|
||||||
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
|
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
|
||||||
type Gates struct {
|
type Gates struct {
|
||||||
Coverage CoverageGate `yaml:"coverage"`
|
Coverage CoverageGate `yaml:"coverage"`
|
||||||
Glossary GlossaryGate `yaml:"glossary"`
|
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
|
// GlossaryGate controls the memory-bank post-check (E1). The post-check ALWAYS runs
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,26 @@ import (
|
||||||
// re-pins loudly). The coverage gate runs ONLY on the TRANSLATOR role's output (vs the
|
// 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
|
// 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).
|
// 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})
|
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
|
return cls
|
||||||
}
|
}
|
||||||
// The coverage gate compares the output against the ORIGINAL source, which is a
|
// The coverage gate compares the output against the ORIGINAL source, which is a
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,15 @@ const (
|
||||||
// retryable (a same-model retry re-produces the same rendering) and NOT auto-
|
// 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).
|
// escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9).
|
||||||
FlagGlossaryMiss FlagReason = "glossary_miss"
|
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
|
// classifierVersion versions the INTRINSIC classify() verdict logic — the refusal
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@ import (
|
||||||
// draft→deepseek-v4-pro hops on acceptance stage A: they inherited 2048/3291 and
|
// 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.
|
// returned length). 0 floor (reasoning-off models) leaves the budget untouched.
|
||||||
// Applied BEFORE the request_hash so it is wire-visible and snapshot-folded.
|
// 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 {
|
func (r *Runner) applyModelFloor(base int, model string) int {
|
||||||
if floor := r.Models.MinMaxTokens(model); floor > base {
|
if floor := r.Models.MinMaxTokens(model); floor > base {
|
||||||
return floor
|
return floor
|
||||||
|
|
@ -76,7 +84,7 @@ type escalationOutcome struct {
|
||||||
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never
|
// (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
|
// reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is
|
||||||
// the attempt loop's terminal (flagged) attempt.
|
// 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
|
var out escalationOutcome
|
||||||
if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
|
if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
@ -108,7 +116,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
|
||||||
if !mayHop {
|
if !mayHop {
|
||||||
return out, nil
|
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 {
|
if err != nil {
|
||||||
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
|
// 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
|
// (and re-abort on every resume): the chunk is already flagged, so keep
|
||||||
|
|
|
||||||
|
|
@ -33,22 +33,31 @@ import (
|
||||||
// CJK-echo draft cured by the single-hop escalation re-gate (ch2), and a hard refusal
|
// 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).
|
// 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
|
// D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now
|
||||||
// on every chunk (both keys are longest-match approved surfaces, not collision-prone
|
// PINNED by golden, not only by the dedicated unit tests —
|
||||||
// downgrades). A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check)
|
// • FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
|
||||||
// is an OPTIONAL fixture extension, deliberately NOT added in this pass: it would be a
|
// capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move,
|
||||||
// fresh ratified golden capture of its own, and this package's re-capture is scoped to
|
// and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000);
|
||||||
// the D24.3/D24.4 diff-class (floor max_tokens + post-check verdicts).
|
// • 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
|
// Update (ONLY on a deliberate, ratified behaviour change — never to "fix" a red
|
||||||
// refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden
|
// refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden
|
||||||
const goldenFile = "testdata/golden/capture.golden"
|
const goldenFile = "testdata/golden/capture.golden"
|
||||||
|
|
||||||
// goldenEchoMarker / goldenRefusalMarker are unique substrings of chapters 2/3 of the
|
// goldenEchoMarker / goldenRefusalMarker / goldenUnionMarker / goldenSanitizerMarker are
|
||||||
// fixture source; the mock provider keys its echo/refusal behaviour off them.
|
// unique substrings of chapters 2/3/5/6 of the fixture source; the mock keys its
|
||||||
|
// echo / refusal / union / sanitizer behaviour off them.
|
||||||
const (
|
const (
|
||||||
goldenEchoMarker = "響動計画"
|
goldenEchoMarker = "響動計画"
|
||||||
goldenRefusalMarker = "拒絶計画"
|
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
|
// 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):
|
case strings.Contains(body, goldenRefusalMarker):
|
||||||
// Both the primary and the fallback refuse → the chunk stays flagged (1 hop).
|
// Both the primary and the fallback refuse → the chunk stays flagged (1 hop).
|
||||||
return "Не могу помочь с этим фрагментом.", "refusal"
|
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):
|
case strings.Contains(body, goldenEchoMarker) && !isEditBody(body):
|
||||||
if req.Model == "fake-fallback" {
|
if req.Model == "fake-fallback" {
|
||||||
// The escalation hop returns a clean translation → the re-gate passes it.
|
// 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.
|
// The primary draft echoes CJK instead of translating → cjk_artifact.
|
||||||
return "夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。", "stop"
|
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):
|
case isEditBody(body):
|
||||||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
|
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
|
||||||
default:
|
default:
|
||||||
|
|
@ -107,9 +134,11 @@ models:
|
||||||
fake-model:
|
fake-model:
|
||||||
provider: fake
|
provider: fake
|
||||||
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
|
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:
|
fake-fallback:
|
||||||
provider: fake
|
provider: fake
|
||||||
price: { input_per_m: 3.0, cached_per_m: 0.3, cache_write_per_m: 0, output_per_m: 6.0 }
|
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))
|
`, time.Now().UTC().Format("2006-01-02"), providerURL))
|
||||||
return filepath.Join(dir, "book.yaml")
|
return filepath.Join(dir, "book.yaml")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postchec
|
||||||
// («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
|
// («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
|
||||||
// requirement): the stored decl forms carry the inflections a boundary regexp would miss.
|
// 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
|
// 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
|
// 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
|
// acceptance-stage-A misses (nominative_gap): an approved dst present in the NOMINATIVE
|
||||||
|
|
|
||||||
|
|
@ -309,19 +309,31 @@ func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEditorPromptIsMonolingual pins decisions-log D1: the real editor prompt sees only the
|
// TestEditorPromptIsBilingual pins the D30.1 flip (D1 monolingual → BILINGUAL editor,
|
||||||
// draft (+ the approved glossary injected as dst constraints by the runner), never the
|
// supersede D17.в): exp12 proved the monolingual editor is both passive and structurally
|
||||||
// source. A reintroduced {{text}} would silently re-anchor the editor to the source and
|
// blind to draft distortions (it cannot know «ударил головой» is a 磕头 kowtow without the
|
||||||
// breed кальки (the main translationese disease D1 exists to avoid).
|
// source). The default editor.md now sees BOTH the source ({{text}}) and the draft
|
||||||
func TestEditorPromptIsMonolingual(t *testing.T) {
|
// ({{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"))
|
raw, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read editor.md: %v", err)
|
t.Fatalf("read editor.md: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(raw), "{{text}}") {
|
if !strings.Contains(string(raw), "{{text}}") {
|
||||||
t.Error("editor.md must NOT reference {{text}} (D1: the editor is monolingual — the source is not injected)")
|
t.Error("editor.md must reference {{text}} (D30.1: the editor is now BILINGUAL — it sees the source)")
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(raw), "{{draft}}") {
|
if !strings.Contains(string(raw), "{{draft}}") {
|
||||||
t.Error("editor.md must reference {{draft}} (it edits the 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}}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
402
backend/internal/pipeline/sanitizer.go
Normal file
402
backend/internal/pipeline/sanitizer.go
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
205
backend/internal/pipeline/sanitizer_test.go
Normal file
205
backend/internal/pipeline/sanitizer_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,25 @@ func (r *Runner) coverageSnapshot() coverageSnap {
|
||||||
return cov
|
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
|
// memoryVersion is the content-hash of the deterministically materialized injected
|
||||||
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm
|
// 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
|
// 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
|
// 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
|
// 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.
|
// part of BriefHash (a book field), so a policy change already re-pins via brief_hash.
|
||||||
StyleCheckVersion string `json:"style_check_version"`
|
StyleCheckVersion string `json:"style_check_version"`
|
||||||
Stages []stageSnap `json:"stages"`
|
// 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(),
|
BriefHash: r.Book.BriefHash(),
|
||||||
ChunkerVersion: chunkerVersion,
|
ChunkerVersion: chunkerVersion,
|
||||||
|
|
@ -200,6 +223,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
|
||||||
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
|
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
|
||||||
Coverage: r.coverageSnapshot(),
|
Coverage: r.coverageSnapshot(),
|
||||||
StyleCheckVersion: cheapGateVersion,
|
StyleCheckVersion: cheapGateVersion,
|
||||||
|
Sanitizer: r.sanitizerSnapshot(),
|
||||||
}
|
}
|
||||||
for _, st := range r.Pipeline.Stages {
|
for _, st := range r.Pipeline.Stages {
|
||||||
ss := stageSnap{
|
ss := stageSnap{
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
|
||||||
maxRegen = 0
|
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 cumCost, runCost float64
|
||||||
var last stageAttempt
|
var last stageAttempt
|
||||||
anyFresh := false
|
anyFresh := false
|
||||||
attemptsMade := 0
|
attemptsMade := 0
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
maxTokens := maxTokensForAttempt(baseMaxTokens, 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 {
|
if err != nil {
|
||||||
return nil, err // infra failure
|
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-
|
// 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.
|
// denied hop keeps the primary flag instead of aborting the book.
|
||||||
escalated, escModel := false, ""
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +217,7 @@ type stageAttempt struct {
|
||||||
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
|
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
|
||||||
// checkpoint. It returns an error only on an infra failure; a bad completion
|
// checkpoint. It returns an error only on an infra failure; a bad completion
|
||||||
// comes back as a classification on the attempt.
|
// 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,
|
reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
|
||||||
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
|
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
|
||||||
att := stageAttempt{reqHash: reqHash, modelActual: model}
|
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.finish = cp.FinishReason
|
||||||
att.modelActual = cp.ModelActual
|
att.modelActual = cp.ModelActual
|
||||||
att.cumCost = cp.CostUSD
|
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 := r.baseRequestLog(st, ch, model, reqHash)
|
||||||
rl.ModelActual = cp.ModelActual
|
rl.ModelActual = cp.ModelActual
|
||||||
rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls)
|
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
|
// non-empty truncated length draft is now classified (flagged/retried), never
|
||||||
// silently passed downstream as OK; an empty completion is flagged too, not a
|
// silently passed downstream as OK; an empty completion is flagged too, not a
|
||||||
// run-crash.
|
// 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 := r.baseRequestLog(st, ch, model, reqHash)
|
||||||
rl.ModelActual = modelActual
|
rl.ModelActual = modelActual
|
||||||
|
|
|
||||||
|
|
@ -103,16 +103,20 @@ func flagReasonSeverity(reason string) int {
|
||||||
return 0
|
return 0
|
||||||
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
|
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
|
||||||
return 1
|
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
|
return 2
|
||||||
case FlagDecodeError:
|
case FlagLoopDegenerate:
|
||||||
return 3
|
return 3
|
||||||
case FlagGlossaryMiss:
|
case FlagDecodeError:
|
||||||
return 4
|
return 4
|
||||||
case FlagLength, FlagEmpty:
|
case FlagGlossaryMiss:
|
||||||
return 5
|
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
|
// bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,11 +1,20 @@
|
||||||
# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2)
|
# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2),
|
||||||
# and a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path.
|
# 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:
|
terms:
|
||||||
- src: 鈴木
|
- src: 鈴木
|
||||||
dst: Судзуки
|
dst: Судзуки
|
||||||
type: name
|
type: name
|
||||||
gender: m
|
gender: m
|
||||||
decl: { invariant: true }
|
decl: { invariant: true }
|
||||||
|
- src: 老人
|
||||||
|
dst: старик
|
||||||
|
type: term
|
||||||
|
decl:
|
||||||
|
# Oblique-only ON PURPOSE (no «старик» nominative) — the union check must supply the base.
|
||||||
|
forms: [старика, старику, стариком, старике]
|
||||||
- src: 魔法学院
|
- src: 魔法学院
|
||||||
dst: Академия магии
|
dst: Академия магии
|
||||||
type: org
|
type: org
|
||||||
|
|
|
||||||
|
|
@ -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
|
core: C1
|
||||||
version: 1
|
version: 1
|
||||||
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
|
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
|
||||||
retries: { regenerate_before_escalate: 1 }
|
retries: { regenerate_before_escalate: 1 }
|
||||||
context: { glossary_injection: selective, glossary_token_budget: 800 }
|
context: { glossary_injection: selective, glossary_token_budget: 800 }
|
||||||
|
gates: { sanitizer: { enabled: true } }
|
||||||
escalation: { budget_usd: 0.5 }
|
escalation: { budget_usd: 0.5 }
|
||||||
stages:
|
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 }
|
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-golden, temperature: 0.3, reasoning: "off", escalate_to: fake-fallback }
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,11 @@
|
||||||
夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。
|
夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。
|
||||||
|
|
||||||
遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。
|
遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。
|
||||||
|
|
||||||
|
第五章 老人の秘密
|
||||||
|
|
||||||
|
鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。
|
||||||
|
|
||||||
|
第六章 序文漏洩
|
||||||
|
|
||||||
|
鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。
|
||||||
|
|
|
||||||
16
backend/prompts/editor-mono.md
Normal file
16
backend/prompts/editor-mono.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
Ты — монолингвальный литературный редактор перевода на русский язык. Ты видишь ТОЛЬКО черновик перевода, без исходного текста.
|
||||||
|
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
|
||||||
|
|
||||||
|
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
|
||||||
|
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским.
|
||||||
|
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
|
||||||
|
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
|
||||||
|
- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку черновика. Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
|
||||||
|
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
|
||||||
|
|
||||||
|
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Черновик перевода для редактуры:
|
||||||
|
|
||||||
|
{{draft}}
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
Ты — литературный редактор художественного перевода на русский язык.
|
Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод.
|
||||||
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
|
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
|
||||||
|
|
||||||
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
|
Твой мандат — художественная редактура черновика со сверкой по исходнику:
|
||||||
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
|
- Сверяй смысл черновика с исходным текстом (язык «{{source_lang}}»): исправляй искажения смысла, неверно понятые реалии, кальки жестов и идиом — переводчик мог передать выражение буквально (напр. поклон как «ударил головой»). Опирайся на исходник, но пиши по-русски, а не подстрочником.
|
||||||
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
|
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским.
|
||||||
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
|
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
|
||||||
- Сохраняй разбивку на абзацы.
|
- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
|
||||||
|
- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
|
||||||
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
|
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
|
||||||
|
|
||||||
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
|
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
|
||||||
|
|
||||||
---USER---
|
---USER---
|
||||||
|
Исходный текст (язык «{{source_lang}}»):
|
||||||
|
|
||||||
|
{{text}}
|
||||||
|
|
||||||
Черновик перевода для редактуры:
|
Черновик перевода для редактуры:
|
||||||
|
|
||||||
{{draft}}
|
{{draft}}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
|
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
|
||||||
- Сноски: {{footnotes}}.
|
- Сноски: {{footnotes}}.
|
||||||
- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.
|
- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.
|
||||||
- Сохраняй разбивку на абзацы и прямую речь как в оригинале.
|
- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—».
|
||||||
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
|
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
|
||||||
|
|
||||||
Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков.
|
Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков.
|
||||||
|
|
||||||
---USER---
|
---USER---
|
||||||
Переведи следующий фрагмент:
|
Переведи следующий фрагмент:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue