Wire the approved glossary into the monolingual editor as CONFIRMED dst-only target constraints (D1) and drop {{text}} from the editor prompt

This commit is contained in:
Claude (backend session) 2026-07-05 15:37:04 +03:00
parent 38c7f3a04a
commit 6a68dfd44e
6 changed files with 138 additions and 14 deletions

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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)")
}
}

View file

@ -4,17 +4,13 @@
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Не меняй имена собственные, термины и реалии — их утверждает другой пасс.
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
- Сохраняй разбивку на абзацы.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
---USER---
Исходник (для сверки смысла, язык «{{source_lang}}»):
{{text}}
Черновик перевода для редактуры:
{{draft}}

View file

@ -492,6 +492,17 @@ keep-alive (Ф12), инъекция глоссария (`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.
## Полигон
(секция параллельной сессии — записи добавлять сюда)