138 lines
5.3 KiB
Go
138 lines
5.3 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|