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:
parent
38c7f3a04a
commit
6a68dfd44e
6 changed files with 138 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,13 @@
|
|||
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
|
||||
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
|
||||
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
|
||||
- Не меняй имена собственные, термины и реалии — их утверждает другой пасс.
|
||||
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
|
||||
- Сохраняй разбивку на абзацы.
|
||||
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
|
||||
|
||||
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
|
||||
|
||||
---USER---
|
||||
Исходник (для сверки смысла, язык «{{source_lang}}»):
|
||||
|
||||
{{text}}
|
||||
|
||||
Черновик перевода для редактуры:
|
||||
|
||||
{{draft}}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## Полигон
|
||||
(секция параллельной сессии — записи добавлять сюда)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue