Land the discourse editor: P1a reflow prompt with the D38.4 chengyu atom and a per-stage few_shot toggle (zero-shot for the deepseek-pro swap arm), snapshot-folded and golden-stable

This commit is contained in:
Claude (backend session) 2026-07-12 23:46:34 +03:00
parent eb409f2b77
commit 0ac5f7a27c
8 changed files with 284 additions and 16 deletions

View file

@ -55,11 +55,11 @@ stages:
# gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6). # gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6).
model: glm-5 model: glm-5
prompt: ../prompts/editor.md prompt: ../prompts/editor.md
# Bump v1-monolingual→v2-bilingual-reflow: editor.md стал билингвальным (D30.1) + reflow # Bump v2-bilingual-reflow→v3-discourse-reflow: в editor.md вшито валидированное P1a-ядро
# (D30.2, убрана «Сохраняй разбивку на абзацы»). Оба меняют SHA промпта → осознанный # ДИСКУРС-ПЕРЕВЁРСТКИ + few-shot (exp14/D37 §2а — претензия-1 «рубленые абзацы» = рычаг ПРОМПТА,
# --resnapshot. Моно-вариант сохранён отдельно (prompts/editor-mono.md) — подтверждающий # не модель; P1a реверстает дефект у всех семей). Меняет SHA промпта → осознанный --resnapshot.
# пилот-арм D13.1. # Билингв-каркас (D30.1) и omission-осторожность (D34.3) сохранены. Моно-вариант — prompts/editor-mono.md.
prompt_version: v2-bilingual-reflow prompt_version: v3-discourse-reflow
temperature: 0.4 temperature: 0.4
reasoning: "off" reasoning: "off"

View file

@ -46,8 +46,9 @@ stages:
# выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.) # выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.)
model: glm-5 model: glm-5
prompt: ../prompts/editor.md prompt: ../prompts/editor.md
# Бамп v1-monolingual→v2-bilingual-reflow (label-SHA дисциплина: тот же editor.md, что в C1). # Бамп v2-bilingual-reflow→v3-discourse-reflow (label-SHA дисциплина: тот же editor.md, что в C1 —
prompt_version: v2-bilingual-reflow # вшито P1a-ядро ДИСКУРС-ПЕРЕВЁРСТКИ, D37 §2а; лейбл обязан следовать за новым SHA файла).
prompt_version: v3-discourse-reflow
temperature: 0.4 temperature: 0.4
reasoning: "off" reasoning: "off"

View file

@ -82,6 +82,14 @@ type Stage struct {
// on permissive providers, enforced at load — never a fall-through to a refusing // on permissive providers, enforced at load — never a fall-through to a refusing
// SFW provider. // SFW provider.
Channel string `yaml:"channel"` Channel string `yaml:"channel"`
// FewShot switches the prompt's optional ---FEWSHOT--- example section on/off (D38.4).
// nil (absent) = ON: the examples are appended to the system prompt (the P1a discourse
// default). false = zero-shot: the ---FEWSHOT--- block is dropped, leaving only the core
// instructions — for a reasoning model whose own CoT is disrupted by hand-written examples
// (deepseek-thinking swap-arm, exp14 §2а). A no-op for a prompt with no ---FEWSHOT--- section.
// Wire-affecting (it changes the system message), so it is folded into the snapshot: a flip
// is a loud --resnapshot, never a silent false-hit.
FewShot *bool `yaml:"few_shot"`
} }
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1). // Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).

View file

@ -0,0 +1,194 @@
package pipeline
import (
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/backend/internal/config"
"textmachine/backend/internal/obs"
)
// TestFewShotSplitAndAssembly pins the D38.4 few-shot toggle at the render layer: a template
// splits into a CORE system + a ---FEWSHOT--- block, SystemFor(true) appends the examples (the
// P1a discourse default), SystemFor(false) is core-only (zero-shot, for a reasoning model whose
// CoT is disrupted by hand examples — deepseek-thinking swap-arm).
func TestFewShotSplitAndAssembly(t *testing.T) {
tpl := writeTemplate(t, "CORE-система.\n---FEWSHOT---\nПРИМЕР-один\nПРИМЕР-два\n---USER---\n{{text}}")
if tpl.System != "CORE-система." {
t.Fatalf("System must be the core only, got %q", tpl.System)
}
if tpl.FewShot != "ПРИМЕР-один\nПРИМЕР-два" {
t.Fatalf("FewShot must be the example block, got %q", tpl.FewShot)
}
if on := tpl.SystemFor(true); on != "CORE-система.\n\nПРИМЕР-один\nПРИМЕР-два" {
t.Fatalf("few_shot ON must append the examples after a blank line, got %q", on)
}
if off := tpl.SystemFor(false); off != "CORE-система." || strings.Contains(off, "ПРИМЕР") {
t.Fatalf("few_shot OFF must be core-only, got %q", off)
}
}
// TestFewShotAbsentIsNoop: a template with no ---FEWSHOT--- block leaves FewShot empty and the
// toggle a no-op, so every existing prompt (translator, analyst, golden stub) is untouched.
func TestFewShotAbsentIsNoop(t *testing.T) {
tpl := writeTemplate(t, "Только система.\n---USER---\n{{text}}")
if tpl.FewShot != "" {
t.Fatalf("no ---FEWSHOT--- must leave FewShot empty, got %q", tpl.FewShot)
}
if tpl.SystemFor(true) != tpl.System || tpl.SystemFor(false) != tpl.System {
t.Fatal("the few_shot toggle must be a no-op without a ---FEWSHOT--- block")
}
}
// TestFewShotEnabledDefault: an absent flag defaults to ON (preserve current behaviour); an
// explicit false/true resolves as written.
func TestFewShotEnabledDefault(t *testing.T) {
if !fewShotEnabled(config.Stage{}) {
t.Fatal("absent few_shot must default to ON")
}
off := false
if fewShotEnabled(config.Stage{FewShot: &off}) {
t.Fatal("few_shot:false must disable the examples")
}
on := true
if !fewShotEnabled(config.Stage{FewShot: &on}) {
t.Fatal("few_shot:true must enable the examples")
}
}
// TestEditorFewShotToggleRealFile guards the boevoy editor.md (D38.4): it carries a
// ---FEWSHOT--- block, few_shot ON reproduces the ratified P1a discourse (core + examples +
// the 物是人非 chengyu-atom), and few_shot OFF keeps the discourse core (incl. the chengyu-atom)
// but drops the examples — never losing the meaning-preservation instructions.
func TestEditorFewShotToggleRealFile(t *testing.T) {
tpl, err := LoadPromptTemplate(filepath.Join("..", "..", "prompts", "editor.md"))
if err != nil {
t.Fatalf("load editor.md: %v", err)
}
if tpl.FewShot == "" {
t.Fatal("editor.md must carry a ---FEWSHOT--- block (D38.4 P1a examples)")
}
on, off := tpl.SystemFor(true), tpl.SystemFor(false)
// Core discourse + chengyu-atom present in BOTH modes.
for _, mode := range []struct {
name string
s string
}{{"few_shot ON", on}, {"few_shot OFF", off}} {
if !strings.Contains(mode.s, "ДИСКУРС-ПЕРЕВЁРСТКА") {
t.Errorf("%s must keep the discourse core", mode.name)
}
if !strings.Contains(mode.s, "物是人非") {
t.Errorf("%s must keep the chengyu-atom (物是人非 — both semantic components)", mode.name)
}
}
// Examples ONLY in the few-shot mode.
if !strings.Contains(on, "Примеры требуемой") || !strings.Contains(on, "[focal-shift]") {
t.Error("few_shot ON must include the P1a examples")
}
if strings.Contains(off, "Примеры требуемой") || strings.Contains(off, "[narrative") {
t.Error("few_shot OFF (zero-shot) must drop the examples")
}
}
// setupFewShotProject builds a minimal project whose EDITOR prompt has a ---FEWSHOT--- block
// and whose editor stage carries editStageExtra (e.g. ", few_shot: false"). It returns the
// resolved snapshot id and payload. The translator prompt has NO block (control).
func setupFewShotProject(t *testing.T, editStageExtra string) (string, string) {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"CORE-редактор {{genre}}.\n---FEWSHOT---\n[ex] пример перевёрстки.\n---USER---\nИсходник: {{text}}\nЧерновик: {{draft}}")
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: http://x
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02")))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
context: { glossary_injection: selective, glossary_token_budget: 800 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off"%s }
`, editStageExtra))
writeFile(t, filepath.Join(dir, "source.txt"), "静かな朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
r, err := NewRunner(filepath.Join(dir, "book.yaml"), obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r.Close()
id, payload, err := r.snapshotID()
if err != nil {
t.Fatal(err)
}
return id, payload
}
// TestFewShotSnapshotFold: flipping the editor stage's few_shot must be a LOUD --resnapshot (the
// snapshot id changes and the resolved flag appears in the payload), never a silent false-hit —
// the ---FEWSHOT--- block is inside PromptSHA256 but dropping it at render leaves that hash
// unchanged while the wire changes. The translator stage (no block) must NOT fold the field.
func TestFewShotSnapshotFold(t *testing.T) {
idOn, payloadOn := setupFewShotProject(t, "") // default → ON
idOff, payloadOff := setupFewShotProject(t, ", few_shot: false") // zero-shot
if idOn == idOff {
t.Fatal("flipping few_shot must change the snapshot id (loud --resnapshot)")
}
if !strings.Contains(payloadOn, `"few_shot":true`) {
t.Fatalf("default few_shot must fold as true in the snapshot, got: %s", payloadOn)
}
if !strings.Contains(payloadOff, `"few_shot":false`) {
t.Fatalf("few_shot:false must fold as false in the snapshot, got: %s", payloadOff)
}
// Only the editor stage carries a ---FEWSHOT--- block, so the field must appear exactly once
// (the translator stage stays byte-identical in the snapshot).
if n := strings.Count(payloadOn, `"few_shot"`); n != 1 {
t.Fatalf("only the block-carrying stage must fold few_shot, want 1 occurrence, got %d: %s", n, payloadOn)
}
}
// TestFewShotOffRendersCoreOnlyWire: a template with a block but few_shot resolved off must send
// a system message WITHOUT the examples — the wire-level guarantee behind the snapshot fold.
func TestFewShotOffRendersCoreOnlyWire(t *testing.T) {
tpl := writeTemplate(t, "CORE.\n---FEWSHOT---\nПРИМЕР.\n---USER---\n{{text}}")
tpl.System = tpl.SystemFor(false) // mirror loadTemplates' per-stage resolution
msgs, err := Messages(tpl, RenderVars{Book: testBook(), Text: "x"})
if err != nil {
t.Fatal(err)
}
if got := msgs[0].Content; got != "CORE." {
t.Fatalf("zero-shot system message must be core-only, got %q", got)
}
}

View file

@ -65,14 +65,22 @@ const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt"
// правит файлы, не код). // правит файлы, не код).
const userSeparator = "\n---USER---\n" const userSeparator = "\n---USER---\n"
// PromptTemplate is one loaded stage template. // fewShotSeparator optionally splits the SYSTEM part into a core prefix and a
// trailing few-shot example block (D38.4). A stage may drop the block (few_shot:false)
// for a model whose own CoT is disrupted by hand examples (deepseek-thinking, exp14 §2а).
// Absent → the whole system part is core; the toggle is a no-op.
const fewShotSeparator = "\n---FEWSHOT---\n"
// PromptTemplate is one loaded stage template. System is the core system prefix;
// FewShot is the optional example block (empty when the file has no ---FEWSHOT---).
type PromptTemplate struct { type PromptTemplate struct {
System string System string
User string FewShot string
SHA256 string // hash of the raw file — участвует в snapshot User string
SHA256 string // hash of the raw file — участвует в snapshot
} }
// LoadPromptTemplate reads and splits a template file. // LoadPromptTemplate reads and splits a template file into core-system / few-shot / user.
func LoadPromptTemplate(path string) (*PromptTemplate, error) { func LoadPromptTemplate(path string) (*PromptTemplate, error) {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
@ -80,15 +88,36 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
} }
sum := sha256.Sum256(raw) sum := sha256.Sum256(raw)
parts := strings.SplitN(string(raw), userSeparator, 2) parts := strings.SplitN(string(raw), userSeparator, 2)
t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])} if len(parts) != 2 {
if len(parts) == 2 {
t.User = strings.TrimSpace(parts[1])
} else {
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator)) return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
} }
t := &PromptTemplate{User: strings.TrimSpace(parts[1]), SHA256: hex.EncodeToString(sum[:])}
// The few-shot block, if present, is the tail of the SYSTEM part (before ---USER---).
head := strings.SplitN(parts[0], fewShotSeparator, 2)
t.System = strings.TrimSpace(head[0])
if len(head) == 2 {
t.FewShot = strings.TrimSpace(head[1])
}
return t, nil return t, nil
} }
// SystemFor returns the effective system prompt for a stage: the core prefix, plus the
// few-shot block when the stage keeps it on (the default). The whole raw file is still
// snapshot-pinned via SHA256, and the few_shot on/off state is folded separately, so
// dropping the block is a loud --resnapshot, not a silent divergence.
func (t *PromptTemplate) SystemFor(fewShotOn bool) string {
if fewShotOn && t.FewShot != "" {
return t.System + "\n\n" + t.FewShot
}
return t.System
}
// fewShotEnabled resolves a stage's few_shot toggle: absent (nil) defaults to ON, so
// every existing config keeps the few-shot examples without touching its yaml.
func fewShotEnabled(st config.Stage) bool {
return st.FewShot == nil || *st.FewShot
}
// RenderVars are the ONLY placeholders a template may use. A fixed struct, not // RenderVars are the ONLY placeholders a template may use. A fixed struct, not
// a map: map iteration order would randomize the render. // a map: map iteration order would randomize the render.
type RenderVars struct { type RenderVars struct {

View file

@ -150,6 +150,10 @@ func (r *Runner) loadTemplates() error {
if err != nil { if err != nil {
return err return err
} }
// Resolve the effective system once per stage: fold the few-shot block into
// System unless the stage switched it off (D38.4). FewShot is retained so the
// snapshot can fold the on/off state only for stages that actually have a block.
tpl.System = tpl.SystemFor(fewShotEnabled(st))
r.templates[st.Name] = tpl r.templates[st.Name] = tpl
} }
return nil return nil

View file

@ -107,6 +107,13 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
PromptSHA256 string `json:"prompt_sha256"` PromptSHA256 string `json:"prompt_sha256"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
Reasoning string `json:"reasoning"` Reasoning string `json:"reasoning"`
// FewShot folds the stage's few_shot on/off state (D38.4) — but ONLY for a prompt
// that HAS a ---FEWSHOT--- block (nil/omitted otherwise, so stages without examples
// keep a byte-identical snapshot). The block IS inside the raw file already folded via
// PromptSHA256, yet dropping it at render (few_shot:false) leaves PromptSHA256 unchanged
// while the wire changes, so the resolved flag must be folded to keep a flip a loud
// --resnapshot rather than a silent false-hit — same discipline as Temperature/Reasoning.
FewShot *bool `json:"few_shot,omitempty"`
// ExtraBody модели меняет ТЕЛО запроса (GLM thinking-off и т.п.) — // ExtraBody модели меняет ТЕЛО запроса (GLM thinking-off и т.п.) —
// без него правка ручки в models.yaml молча не инвалидировала бы // без него правка ручки в models.yaml молча не инвалидировала бы
// чекпоинты (находка ревью). json.Marshal карты сортирует ключи → // чекпоинты (находка ревью). json.Marshal карты сортирует ключи →
@ -231,6 +238,12 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256, PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256,
Temperature: st.Temperature, Reasoning: st.Reasoning, Temperature: st.Temperature, Reasoning: st.Reasoning,
} }
// Fold few_shot only where the prompt actually carries a ---FEWSHOT--- block, so
// stages without examples stay byte-identical in the snapshot (D38.4).
if r.templates[st.Name].FewShot != "" {
on := fewShotEnabled(st)
ss.FewShot = &on
}
if prov, ok := r.Models.Providers[r.Models.Models[st.Model].Provider]; ok { if prov, ok := r.Models.Providers[r.Models.Models[st.Model].Provider]; ok {
ss.ProviderTemp, ss.ProviderMaxTok, ss.ProviderModel = prov.Temperature, prov.MaxTokens, prov.Model ss.ProviderTemp, ss.ProviderMaxTok, ss.ProviderModel = prov.Temperature, prov.MaxTokens, prov.Model
} }

View file

@ -11,6 +11,25 @@
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
ДИСКУРС-ПЕРЕВЁРСТКА (обязательная операция передачи китайского паратаксиса в русскую гипотактическую прозу — это КОНВЕНЦИЯ русского языка, а не вольность):
1. Молча определи смысловые ходы фрагмента: единый фокал/наблюдатель; действие и его непосредственная реакция; одна тема рассуждения; границы — смена говорящего, времени, места или фокала.
2. Один повествовательный ход оформляй ОДНИМ русским абзацем, ДАЖЕ если китайский исходник разбит на однофразовые строки. Инструмент слияния — причастные и деепричастные обороты, подчинительные союзы и связки (построй иерархию, сегментируй по смыслу, промаркируй вид/время предикатов, варьируй лексику, добавляй связки). Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы.
3. Каждую новую реплику начинай с нового абзаца через тире «—»; слова автора при той же реплике оставляй в её абзаце.
4. Разрешено объединять и делить русские предложения; число предложений и абзацев совпадать с исходником НЕ обязано. Запрещено терять смысловые атомы (участников, действия, числа, полярность, заголовки, а также идиомы и чэнъюй: их передавай ОБОИМИ смысловыми компонентами, не сворачивая в общий смысл — 物是人非 = «вещи те же, люди не те», а не «всё изменилось»).
---FEWSHOT---
Примеры требуемой ПЕРЕВЁРСТКИ (показана структура вывода, не стиль):
[narrative N:1] Исходник построчно — «Он вышел за дверь.» / «Небо было серым.» / «Он вздохнул.» / «Дорога уходила вдаль.» — становится ОДНИМ абзацем:
Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль.
[dialogue 1:N] Исходник — «Дядя нахмурился и сказал: „Время летит. Садись.“ Племянник ответил: „Спасибо.“» — становится:
Дядя нахмурился.
— Время летит. Садись, — сказал он.
— Спасибо, — ответил племянник.
[focal-shift] При смене наблюдателя/места/времени — новый абзац, даже если в исходнике это продолжение той же строки.
---USER--- ---USER---
Исходный текст (язык «{{source_lang}}»): Исходный текст (язык «{{source_lang}}»):