diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml index 3de562a..d5ffa1e 100644 --- a/backend/configs/pipeline-c1.yaml +++ b/backend/configs/pipeline-c1.yaml @@ -55,11 +55,11 @@ stages: # gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6). model: glm-5 prompt: ../prompts/editor.md - # Bump v1-monolingual→v2-bilingual-reflow: editor.md стал билингвальным (D30.1) + reflow - # (D30.2, убрана «Сохраняй разбивку на абзацы»). Оба меняют SHA промпта → осознанный - # --resnapshot. Моно-вариант сохранён отдельно (prompts/editor-mono.md) — подтверждающий - # пилот-арм D13.1. - prompt_version: v2-bilingual-reflow + # Bump v2-bilingual-reflow→v3-discourse-reflow: в editor.md вшито валидированное P1a-ядро + # ДИСКУРС-ПЕРЕВЁРСТКИ + few-shot (exp14/D37 §2а — претензия-1 «рубленые абзацы» = рычаг ПРОМПТА, + # не модель; P1a реверстает дефект у всех семей). Меняет SHA промпта → осознанный --resnapshot. + # Билингв-каркас (D30.1) и omission-осторожность (D34.3) сохранены. Моно-вариант — prompts/editor-mono.md. + prompt_version: v3-discourse-reflow temperature: 0.4 reasoning: "off" diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml index a605491..f5baf07 100644 --- a/backend/configs/pipeline-c2.yaml +++ b/backend/configs/pipeline-c2.yaml @@ -46,8 +46,9 @@ stages: # выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.) model: glm-5 prompt: ../prompts/editor.md - # Бамп v1-monolingual→v2-bilingual-reflow (label-SHA дисциплина: тот же editor.md, что в C1). - prompt_version: v2-bilingual-reflow + # Бамп v2-bilingual-reflow→v3-discourse-reflow (label-SHA дисциплина: тот же editor.md, что в C1 — + # вшито P1a-ядро ДИСКУРС-ПЕРЕВЁРСТКИ, D37 §2а; лейбл обязан следовать за новым SHA файла). + prompt_version: v3-discourse-reflow temperature: 0.4 reasoning: "off" diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index adc21d5..7a934f0 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -82,6 +82,14 @@ type Stage struct { // on permissive providers, enforced at load — never a fall-through to a refusing // SFW provider. 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). diff --git a/backend/internal/pipeline/fewshot_test.go b/backend/internal/pipeline/fewshot_test.go new file mode 100644 index 0000000..2f7d642 --- /dev/null +++ b/backend/internal/pipeline/fewshot_test.go @@ -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) + } +} diff --git a/backend/internal/pipeline/render.go b/backend/internal/pipeline/render.go index 247ba94..fbb98a3 100644 --- a/backend/internal/pipeline/render.go +++ b/backend/internal/pipeline/render.go @@ -65,14 +65,22 @@ const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt" // правит файлы, не код). 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 { - System string - User string - SHA256 string // hash of the raw file — участвует в snapshot + System string + FewShot string + 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) { raw, err := os.ReadFile(path) if err != nil { @@ -80,15 +88,36 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) { } sum := sha256.Sum256(raw) parts := strings.SplitN(string(raw), userSeparator, 2) - t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])} - if len(parts) == 2 { - t.User = strings.TrimSpace(parts[1]) - } else { + if len(parts) != 2 { 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 } +// 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 // a map: map iteration order would randomize the render. type RenderVars struct { diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index ec4efa6..580b099 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -150,6 +150,10 @@ func (r *Runner) loadTemplates() error { if err != nil { 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 } return nil diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go index fa42971..97c3d51 100644 --- a/backend/internal/pipeline/snapshot.go +++ b/backend/internal/pipeline/snapshot.go @@ -107,6 +107,13 @@ func (r *Runner) snapshotID() (id, payload string, err error) { PromptSHA256 string `json:"prompt_sha256"` Temperature float64 `json:"temperature"` 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 и т.п.) — // без него правка ручки в models.yaml молча не инвалидировала бы // чекпоинты (находка ревью). json.Marshal карты сортирует ключи → @@ -231,6 +238,12 @@ func (r *Runner) snapshotID() (id, payload string, err error) { PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256, 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 { ss.ProviderTemp, ss.ProviderMaxTok, ss.ProviderModel = prov.Temperature, prov.MaxTokens, prov.Model } diff --git a/backend/prompts/editor.md b/backend/prompts/editor.md index 63f9601..61bca10 100644 --- a/backend/prompts/editor.md +++ b/backend/prompts/editor.md @@ -11,6 +11,25 @@ Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. +ДИСКУРС-ПЕРЕВЁРСТКА (обязательная операция передачи китайского паратаксиса в русскую гипотактическую прозу — это КОНВЕНЦИЯ русского языка, а не вольность): +1. Молча определи смысловые ходы фрагмента: единый фокал/наблюдатель; действие и его непосредственная реакция; одна тема рассуждения; границы — смена говорящего, времени, места или фокала. +2. Один повествовательный ход оформляй ОДНИМ русским абзацем, ДАЖЕ если китайский исходник разбит на однофразовые строки. Инструмент слияния — причастные и деепричастные обороты, подчинительные союзы и связки (построй иерархию, сегментируй по смыслу, промаркируй вид/время предикатов, варьируй лексику, добавляй связки). Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы. +3. Каждую новую реплику начинай с нового абзаца через тире «—»; слова автора при той же реплике оставляй в её абзаце. +4. Разрешено объединять и делить русские предложения; число предложений и абзацев совпадать с исходником НЕ обязано. Запрещено терять смысловые атомы (участников, действия, числа, полярность, заголовки, а также идиомы и чэнъюй: их передавай ОБОИМИ смысловыми компонентами, не сворачивая в общий смысл — 物是人非 = «вещи те же, люди не те», а не «всё изменилось»). + +---FEWSHOT--- +Примеры требуемой ПЕРЕВЁРСТКИ (показана структура вывода, не стиль): + +[narrative N:1] Исходник построчно — «Он вышел за дверь.» / «Небо было серым.» / «Он вздохнул.» / «Дорога уходила вдаль.» — становится ОДНИМ абзацем: +Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль. + +[dialogue 1:N] Исходник — «Дядя нахмурился и сказал: „Время летит. Садись.“ Племянник ответил: „Спасибо.“» — становится: +Дядя нахмурился. +— Время летит. Садись, — сказал он. +— Спасибо, — ответил племянник. + +[focal-shift] При смене наблюдателя/места/времени — новый абзац, даже если в исходнике это продолжение той же строки. + ---USER--- Исходный текст (язык «{{source_lang}}»):