textmachine/backend/internal/pipeline/systemmessages_wave_test.go

138 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"strings"
"testing"
)
// systemmessages_wave_test.go proves the SystemMessages fix on the two paths that actually carry
// the memory bank to a model — the DRAFT wave (the translator's src→dst glossary) and the EDIT
// wave (the editor's CONFIRMED-dst constraint block). The edit wave matters on its own account:
// it is the wave that delivers the OWNER'S SIGNED decisions, so a hop that ate its system message
// would silently eat the owner's edits, not merely a machine's proposals.
//
// ⚠ The assertion is the POST-FIX invariant, deliberately: "the injection is in the request" is
// true of unfixed code too (the assembler always built it; the endpoint dropped it), so it would
// be green on a broken engine. What separates fixed from unfixed is ONE system message with the
// injection INSIDE it.
// systemMessagesOf returns the wire body's system messages, in order.
func systemMessagesOf(t *testing.T, body string) []wireMsg {
t.Helper()
var out []wireMsg
for _, m := range bodyMessages(t, body) {
if m.Role == "system" {
out = append(out, m)
}
}
return out
}
// TestSingleSystemEndpointCarriesTheBankOnBothWaves runs a real two-wave book against a provider
// declared single-system and asserts, for the draft AND the edit request, that the endpoint got
// exactly one system message and that the bank block is inside it.
func TestSingleSystemEndpointCarriesTheBankOnBothWaves(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
}
return "Судзуки пошёл в тихую библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, systemMessagesSingle: true,
})
r := newRunner(t, bookPath)
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Flagged != 0 {
t.Fatalf("the join must not disturb the verdict: flagged=%d", res.Flagged)
}
cases := []struct {
wave string
isEdit bool
wantBlock string // the bank text this wave's role receives
}{
// waverun.go runDraftChunk: the translator's src→dst glossary.
{wave: "draft", isEdit: false, wantBlock: "ГЛОССАРИЙ"},
// waverun.go runEditUnit: the editor's CONFIRMED-dst constraint block — the carrier of the
// owner's signed decisions.
{wave: "edit", isEdit: true, wantBlock: "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ"},
}
for _, c := range cases {
t.Run(c.wave, func(t *testing.T) {
seen := 0
for _, body := range rec.all() {
if isEditBody(body) != c.isEdit {
continue
}
seen++
sys := systemMessagesOf(t, body)
if len(sys) != 1 {
t.Fatalf("%s wave: a single-system endpoint received %d system messages — the bank is being dropped by the provider",
c.wave, len(sys))
}
if !strings.Contains(sys[0].Content, c.wantBlock) {
t.Fatalf("%s wave: the bank block %q is not inside the single system message: %q",
c.wave, c.wantBlock, sys[0].Content)
}
if !strings.Contains(sys[0].Content, "Судзуки") {
t.Fatalf("%s wave: the approved rendering did not reach the model: %q", c.wave, sys[0].Content)
}
// The stable prompt prefix must stay FIRST inside the join, or the provider's prefix
// cache stops hitting on every call.
iPrefix := strings.Index(sys[0].Content, "Редактируй перевод.")
if !c.isEdit {
iPrefix = strings.Index(sys[0].Content, "Переводи с")
}
if iPrefix != 0 {
t.Fatalf("%s wave: the stable prefix is not first in the join (index %d): %q", c.wave, iPrefix, sys[0].Content)
}
// The user turn survives the join untouched, and the bank never leaks into it.
msgs := bodyMessages(t, body)
if len(msgs) != 2 || msgs[1].Role != "user" {
t.Fatalf("%s wave: wire shape = %+v, want [system user]", c.wave, msgs)
}
if strings.Contains(msgs[1].Content, c.wantBlock) {
t.Fatalf("%s wave: the bank leaked into the user message: %q", c.wave, msgs[1].Content)
}
}
if seen == 0 {
t.Fatalf("%s wave recorded no request", c.wave)
}
})
}
}
// TestMultiSystemEndpointIsUnchangedOnBothWaves is the control on the SAME book: a provider that
// declares nothing still receives the two-message wire, so the fix cannot have changed what every
// other provider in the stack sees.
func TestMultiSystemEndpointIsUnchangedOnBothWaves(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
}
return "Судзуки пошёл в тихую библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
for _, body := range rec.all() {
if sys := systemMessagesOf(t, body); len(sys) != 2 {
t.Fatalf("an undeclared endpoint must keep the two-message wire, got %d: %+v", len(sys), sys)
}
}
}