diff --git a/backend/internal/pipeline/memory.go b/backend/internal/pipeline/memory.go index 3c4744f..1c7c304 100644 --- a/backend/internal/pipeline/memory.go +++ b/backend/internal/pipeline/memory.go @@ -442,6 +442,42 @@ func renderGlossaryBlock(injected []pickedEntry) string { return glossaryBlockHeader + "\n" + strings.Join(lines, "\n") } +// editorConstraintHeader introduces the monolingual editor's dst-constraint block (D1). +// The editor never sees the source, so it gets ONLY the approved target renderings as +// consistency constraints — it must normalise any divergent rendering in the draft to +// these canonical forms (inflecting for context) and touch nothing else. +const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):" + +// renderEditorConstraintBlock serializes the selected records' CONFIRMED dst renderings into +// the monolingual editor's injection (D1). Unlike renderGlossaryBlock it emits the TARGET +// forms only — no "src → dst" (the editor never sees the source: decisions-log D1). It is +// CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the translator MAY have +// legitimately rejected, so forcing the editor to rewrite the draft toward it would corrupt +// a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1). +// Deduped by dst (an alias and its main entry share a rendering), in the budget priority +// order fixed by Select. Returns "" when nothing CONFIRMED renders → the editor gets NO +// injection (the plain draft-only layout). The block is a subset of the already +// budget-limited memSel.injected, so it needs no separate token budget. +func renderEditorConstraintBlock(injected []pickedEntry) string { + var lines []string + seen := map[string]bool{} + for _, p := range injected { + if p.disp != memConfirmed { + continue + } + dst := strings.TrimSpace(p.entry.dst) + if dst == "" || seen[dst] { + continue + } + seen[dst] = true + lines = append(lines, "- «"+dst+"»") + } + if len(lines) == 0 { + return "" + } + return editorConstraintHeader + "\n" + strings.Join(lines, "\n") +} + // --- Aho-Corasick multi-pattern automaton over runes ---------------------------- type acMatch struct { diff --git a/backend/internal/pipeline/memory_test.go b/backend/internal/pipeline/memory_test.go index d1c7379..7d36b1f 100644 --- a/backend/internal/pipeline/memory_test.go +++ b/backend/internal/pipeline/memory_test.go @@ -426,3 +426,40 @@ func TestInjectivityCollision(t *testing.T) { t.Fatalf("want 1 dst collision, got %d: %v", len(cols), cols) } } + +// TestRenderEditorConstraintBlock covers D1: the monolingual editor's injection is the +// CONFIRMED dst forms ONLY (target constraints), deduped, with no source or "src → dst". +// Reverting the CONFIRMED-only guard leaks an AMBIGUOUS candidate and fails here. +func TestRenderEditorConstraintBlock(t *testing.T) { + suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"} + cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS + tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"} + tanakaKana := &memoryEntry{src: "たなか", dst: "Танака", status: "approved"} // same dst → deduped + sel := []pickedEntry{ + {entry: suzuki, via: "鈴木", disp: memConfirmed}, + {entry: cand, via: "小d", disp: memAmbiguous}, + {entry: tanaka, via: "田中", disp: memConfirmed}, + {entry: tanakaKana, via: "たなか", disp: memConfirmed}, + } + block := renderEditorConstraintBlock(sel) + + if !strings.Contains(block, "«Судзуки»") || !strings.Contains(block, "«Танака»") { + t.Errorf("confirmed dst forms missing: %q", block) + } + // AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering. + if strings.Contains(block, "Малыш Дэ") { + t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", block) + } + // dst-only: no source key, no arrow (the editor is monolingual). + if strings.Contains(block, "→") || strings.Contains(block, "鈴木") || strings.Contains(block, "ГЛОССАРИЙ") { + t.Errorf("editor block must be target-only, no src→dst: %q", block) + } + // Deduped by dst (Танака appears once despite two source keys). + if n := strings.Count(block, "«Танака»"); n != 1 { + t.Errorf("dst not deduped: «Танака» appears %d times: %q", n, block) + } + // An all-AMBIGUOUS (or empty) selection yields no block at all. + if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" { + t.Errorf("an all-AMBIGUOUS selection must yield no editor block, got %q", got) + } +} diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index 152c1a0..edda6eb 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -33,6 +33,10 @@ import ( // translation — the only role the excision coverage-gate is meaningful on. const roleTranslator = "translator" +// roleEditor is the monolingual editor stage (D1): it receives the draft plus the approved +// glossary's dst forms as target-consistency constraints, never the source. +const roleEditor = "editor" + // errReserveCeiling is wrapped into the error runAttempt returns when a book/day USD // ceiling denies a reservation. The PRIMARY path propagates it (the book durably // pauses and resumes once the ceiling is raised, D4); the OPTIONAL escalation hop @@ -681,11 +685,12 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st // serialize the translator injection block. Recomputed on every run (resumed chunks // included) so the sticky window and the injected bytes are resume-stable. var memSel memorySelection - var translatorInjection string + var translatorInjection, editorInjection string activeIDs := map[string]bool{} if r.memory != nil { memSel = r.memory.Select(ch.Text, ch.Chapter, stickyPrev, r.Pipeline.Context.GlossaryTokenBudget) translatorInjection = renderGlossaryBlock(memSel.injected) + editorInjection = renderEditorConstraintBlock(memSel.injected) activeIDs = memSel.activeIDs } @@ -707,13 +712,18 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st continue } - // Inject the glossary ONLY into the translator (шаг 4 scope): its src→dst records - // are matched against the source chunk. Other stages (the monolingual editor) get - // no injection in v1 (a dst-constraint injection for the editor is a fast-follow on - // this same surface). Empty injection → the plain 2-message layout. + // Inject the glossary per role. The TRANSLATOR gets the src→dst block (its keys are + // matched against the source chunk); the monolingual EDITOR gets the CONFIRMED dst + // forms as target-consistency constraints (no source — decisions-log D1); every other + // stage gets none. Empty injection → the plain 2-message layout. The injection is a + // message, so it enters this stage's request_hash automatically (a resumed chunk + // reproduces it deterministically from the frozen bank). injection := "" - if st.Role == roleTranslator { + switch st.Role { + case roleTranslator: injection = translatorInjection + case roleEditor: + injection = editorInjection } sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev, injection) if err != nil { diff --git a/backend/internal/pipeline/runner_memory_test.go b/backend/internal/pipeline/runner_memory_test.go index c97b5fc..9b7b8b4 100644 --- a/backend/internal/pipeline/runner_memory_test.go +++ b/backend/internal/pipeline/runner_memory_test.go @@ -89,11 +89,28 @@ func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) { if strings.Contains(msgs[2].Content, "ГЛОССАРИЙ") { t.Errorf("glossary leaked into the user (ch.Text) message: %q", msgs[2].Content) } - // The EDITOR gets no injection in v1. + // D1: the monolingual EDITOR now gets the CONFIRMED dst forms as target constraints — its + // OWN dst-constraint block (distinct header), NOT the translator's "src → dst" ГЛОССАРИЙ, + // and never the source key inside the injection. + editorSaw := false for _, b := range rec.all() { - if isEditBody(b) && strings.Contains(b, "ГЛОССАРИЙ") { - t.Error("editor received a glossary injection (not in v1 scope)") + if !isEditBody(b) { + continue } + em := bodyMessages(t, b) + if len(em) != 3 || em[1].Role != "system" { + t.Fatalf("editor must have 3 messages (system, dst-constraints, user), got %d: %+v", len(em), em) + } + if !strings.Contains(em[1].Content, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") || !strings.Contains(em[1].Content, "Судзуки") { + t.Errorf("editor dst-constraint block missing/wrong: %q", em[1].Content) + } + if strings.Contains(em[1].Content, "ГЛОССАРИЙ") || strings.Contains(em[1].Content, "→") || strings.Contains(em[1].Content, "鈴木") { + t.Errorf("editor block must be dst-only (no ГЛОССАРИЙ header / arrow / source): %q", em[1].Content) + } + editorSaw = true + } + if !editorSaw { + t.Error("editor stage recorded no request") } // retrieval-state: one exact hit, post-check ran with 0 misses. @@ -291,3 +308,20 @@ func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) { t.Fatal("resnapshot run must re-call the provider for the re-translation") } } + +// TestEditorPromptIsMonolingual pins decisions-log D1: the real editor prompt sees only the +// draft (+ the approved glossary injected as dst constraints by the runner), never the +// source. A reintroduced {{text}} would silently re-anchor the editor to the source and +// breed кальки (the main translationese disease D1 exists to avoid). +func TestEditorPromptIsMonolingual(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor.md")) + if err != nil { + t.Fatalf("read editor.md: %v", err) + } + if strings.Contains(string(raw), "{{text}}") { + t.Error("editor.md must NOT reference {{text}} (D1: the editor is monolingual — the source is not injected)") + } + if !strings.Contains(string(raw), "{{draft}}") { + t.Error("editor.md must reference {{draft}} (it edits the draft)") + } +} diff --git a/backend/prompts/editor.md b/backend/prompts/editor.md index 6a0d177..7540343 100644 --- a/backend/prompts/editor.md +++ b/backend/prompts/editor.md @@ -4,17 +4,13 @@ Твой мандат УЗКИЙ — художественная редактура черновика перевода: - Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции. - Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. -- Не меняй имена собственные, термины и реалии — их утверждает другой пасс. +- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. - Сохраняй разбивку на абзацы. - Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений. ---USER--- -Исходник (для сверки смысла, язык «{{source_lang}}»): - -{{text}} - Черновик перевода для редактуры: {{draft}} diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 48670d9..21b0747 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -492,6 +492,17 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор Отсев и 12 миноров оставлены как задокументированные размены/fast-follow (per-lang boundary-тесты post-check, sticky-decay coverage, apostrophe-fold — не гарят на дефолте). Вердикт сессии: **ПРИНЯТО**, шаг 4 закрыт. +### 2026-07-05 — D1: инъекция глоссария в редактор (монолингвальный редактор) + +Первый fast-follow на поверхности памяти (eval-приоритет). Редактор теперь получает одобренный глоссарий как **target-констрейнты** и переведён в **монолингвальный** режим (decisions-log D1). +- **`renderEditorConstraintBlock` (memory.go):** dst-only блок (без `src → dst` — редактор не видит исходник), **CONFIRMED-only** (AMBIGUOUS — кандидат, который переводчик вправе отклонить; форсить редактора переписывать черновик под непроверенное = порча корректного перевода, зеркалит gate-CONFIRMED-only #1), дедуп по dst, подмножество уже забюджеченного `memSel.injected` (свой токен-бюджет не нужен). Пустой → редактор без инъекции. +- **runner.go:** `roleEditor`-ветка в стадийном цикле; `editorInjection` из того же `memSel` (детерминизм, resume-стабилен, входит в editor `request_hash` через msgs — не в snapshotID, memoryVersion уже там). +- **`prompts/editor.md`:** убран `{{text}}` (D1: билингвальный редактор якорится к исходнику → кальки, главная translationese-болезнь; монолингвальный ещё и дешевле — исходник не сидит на входе каждого edit-вызова); мандат строки 7 согласован с инъекцией («приводи к каноническим формам глоссария», а не «не трогай»). + +Тесты **мутационно-проверены** (реверт → падает именно его тест): `TestRenderEditorConstraintBlock` (CONFIRMED-only / dst-only / дедуп), обновлён `TestRunnerMemoryInjectionAndPostcheck` (editor получает dst-констрейнт-блок «КАНОНИЧЕСКИЕ ПЕРЕВОДЫ», не «src→dst ГЛОССАРИЙ»), `TestEditorPromptIsMonolingual` (guard: реальный editor.md без `{{text}}`). Полный `-race` зелёный, `tmctl report` $0. + +**Заметка владельцу/оркестратору:** редактор стал **источник-слепым** (D1) — правит стиль/консистентность имён, но не ловит мистранслейты (это судья/эскалация, Фаза 2). Осознанный размен по контракту D1. + ## Полигон (секция параллельной сессии — записи добавлять сюда)