245 lines
8.3 KiB
Go
245 lines
8.3 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// runner_memory_test.go: the шаг-4 acceptance — the memory bank v2 mechanism end-to-end
|
|
// on a seeded glossary against the mock provider (injection → translate → post-check →
|
|
// retrieval-state → resume $0 → resnapshot on an approved change). Per §8, acceptance is
|
|
// "the mechanism is correct on a seeded glossary", not "≥98% on a real book".
|
|
|
|
const suzukiSeed = `
|
|
terms:
|
|
- src: 鈴木
|
|
dst: Судзуки
|
|
type: name
|
|
status: approved
|
|
decl: { invariant: true, forms: ["Судзуки"] }
|
|
`
|
|
|
|
// suzukiSource is one ja chunk that names 鈴木 (so the glossary fires).
|
|
const suzukiSource = "鈴木は静かな図書館へ行った。"
|
|
|
|
type wireMsg struct {
|
|
Role string
|
|
Content string
|
|
}
|
|
|
|
func bodyMessages(t *testing.T, body string) []wireMsg {
|
|
t.Helper()
|
|
var req struct {
|
|
Messages []wireMsg `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
t.Fatalf("bad request body: %v\n%s", err, body)
|
|
}
|
|
return req.Messages
|
|
}
|
|
|
|
// translatorBody returns the recorded draft(translator) request body.
|
|
func translatorBody(t *testing.T, rec *reqRec) string {
|
|
t.Helper()
|
|
for _, b := range rec.all() {
|
|
if !isEditBody(b) {
|
|
return b
|
|
}
|
|
}
|
|
t.Fatal("no translator request recorded")
|
|
return ""
|
|
}
|
|
|
|
func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
|
if isEditBody(body) {
|
|
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
|
|
}
|
|
return "Судзуки пошёл в тихую библиотеку.", "stop" // renders the approved dst
|
|
})
|
|
defer srv.Close()
|
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed})
|
|
ctx := context.Background()
|
|
|
|
r := newRunner(t, bookPath)
|
|
res, err := r.TranslateBook(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Flagged != 0 {
|
|
t.Fatalf("flagger default must not flag a correct render: flagged=%d", res.Flagged)
|
|
}
|
|
|
|
// The TRANSLATOR request carries the injection as its own system message between the
|
|
// stable prefix and the user chunk; the dst appears only via injection (not in the ja
|
|
// source), so its presence proves the injection landed on the wire.
|
|
tb := translatorBody(t, rec)
|
|
msgs := bodyMessages(t, tb)
|
|
if len(msgs) != 3 {
|
|
t.Fatalf("translator must have 3 messages (system, injection, user), got %d: %+v", len(msgs), msgs)
|
|
}
|
|
if msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "Судзуки") || !strings.Contains(msgs[1].Content, "ГЛОССАРИЙ") {
|
|
t.Errorf("injection message wrong: %+v", msgs[1])
|
|
}
|
|
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.
|
|
for _, b := range rec.all() {
|
|
if isEditBody(b) && strings.Contains(b, "ГЛОССАРИЙ") {
|
|
t.Error("editor received a glossary injection (not in v1 scope)")
|
|
}
|
|
}
|
|
|
|
// retrieval-state: one exact hit, post-check ran with 0 misses.
|
|
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
|
|
if err != nil || rs == nil {
|
|
t.Fatalf("retrieval-state missing: %v", err)
|
|
}
|
|
if rs.NExactHits != 1 || rs.NPostcheckMiss != 0 {
|
|
t.Errorf("retrieval-state wrong: exact=%d miss=%d", rs.NExactHits, rs.NPostcheckMiss)
|
|
}
|
|
callsRun1 := rec.count()
|
|
r.Close()
|
|
|
|
// Resume: no new provider calls, $0 (the deterministic memory path re-derives for free).
|
|
r2 := newRunner(t, bookPath)
|
|
defer r2.Close()
|
|
res2, err := r2.TranslateBook(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.count() != callsRun1 || res2.TotalUSD != 0 {
|
|
t.Fatalf("resume must be $0 with no new calls: calls=%d (run1=%d) usd=%v", rec.count(), callsRun1, res2.TotalUSD)
|
|
}
|
|
// retrieval-state re-derived identically.
|
|
rs2, _ := r2.Store.GetRetrievalState("test-book", 1, 0)
|
|
if rs2 == nil || rs2.NExactHits != 1 || rs2.NPostcheckMiss != 0 {
|
|
t.Errorf("retrieval-state not stable on resume: %+v", rs2)
|
|
}
|
|
}
|
|
|
|
func TestRunnerMemoryFlaggerRecordsMissWithoutFlagging(t *testing.T) {
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
|
if isEditBody(body) {
|
|
return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
|
|
}
|
|
return "Некто пошёл в библиотеку.", "stop" // DROPS the approved name → a post-check miss
|
|
})
|
|
defer srv.Close()
|
|
// Default flagger mode (postcheckGate: false).
|
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed})
|
|
|
|
r := newRunner(t, bookPath)
|
|
defer r.Close()
|
|
res, err := r.TranslateBook(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Flagger mode: the chunk is NOT flagged (translation still exported), but the miss IS
|
|
// recorded (silent degradation converted to a loud, visible record).
|
|
if res.Flagged != 0 {
|
|
t.Errorf("flagger mode must not flag the chunk: flagged=%d", res.Flagged)
|
|
}
|
|
rs, _ := r.Store.GetRetrievalState("test-book", 1, 0)
|
|
if rs == nil || rs.NPostcheckMiss != 1 {
|
|
t.Fatalf("post-check miss not recorded: %+v", rs)
|
|
}
|
|
if !strings.Contains(rs.PostcheckDetail, "鈴木") {
|
|
t.Errorf("post-check detail should name the missed src: %q", rs.PostcheckDetail)
|
|
}
|
|
}
|
|
|
|
func TestRunnerMemoryPostcheckGateFlags(t *testing.T) {
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
|
if isEditBody(body) {
|
|
return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
|
|
}
|
|
return "Некто пошёл в библиотеку.", "stop" // drops the name
|
|
})
|
|
defer srv.Close()
|
|
// Opt-in hard gate.
|
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
|
|
|
|
r := newRunner(t, bookPath)
|
|
defer r.Close()
|
|
res, err := r.TranslateBook(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Flagged != 1 {
|
|
t.Fatalf("gate mode must flag the missed chunk: flagged=%d", res.Flagged)
|
|
}
|
|
if res.Chunks[0].FlagReason != FlagGlossaryMiss {
|
|
t.Errorf("flag reason = %q, want glossary_miss", res.Chunks[0].FlagReason)
|
|
}
|
|
// The post-check runs on the FINAL exported output, so both stages ran (each ok in
|
|
// chunk_status), but the CHUNK is flagged and its text is not exported.
|
|
if res.Chunks[0].FinalText != "" {
|
|
t.Errorf("a glossary-flagged chunk must not export text, got %q", res.Chunks[0].FinalText)
|
|
}
|
|
var editorRan bool
|
|
for _, st := range res.Chunks[0].Stages {
|
|
if st.Role == "editor" && st.Disposition == DispOK {
|
|
editorRan = true
|
|
}
|
|
}
|
|
if !editorRan {
|
|
t.Error("editor stage should have run (post-check validates the final/editor output, not the draft)")
|
|
}
|
|
}
|
|
|
|
func TestRunnerMemoryResnapshotOnApprovedChange(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})
|
|
ctx := context.Background()
|
|
|
|
r1 := newRunner(t, bookPath)
|
|
if _, err := r1.TranslateBook(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r1.Close()
|
|
callsAfterRun1 := rec.count()
|
|
|
|
// Change the APPROVED dst in the seed → the frozen materialization changes → the
|
|
// snapshot changes → a resume without --resnapshot must fail loud (F1: no silent
|
|
// divergent re-pay).
|
|
seedPath := filepath.Join(filepath.Dir(bookPath), "glossary-seed.yaml")
|
|
if err := os.WriteFile(seedPath, []byte(strings.Replace(suzukiSeed, "Судзуки", "Сузуки", -1)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r2 := newRunner(t, bookPath)
|
|
_, err := r2.TranslateBook(ctx)
|
|
r2.Close()
|
|
if err == nil || !strings.Contains(err.Error(), "resnapshot") {
|
|
t.Fatalf("changed approved glossary must fail loud mentioning --resnapshot, got: %v", err)
|
|
}
|
|
if rec.count() != callsAfterRun1 {
|
|
t.Fatalf("denied resume must not call the provider: calls=%d (after run1=%d)", rec.count(), callsAfterRun1)
|
|
}
|
|
|
|
// With --resnapshot the book re-translates under the new approved glossary.
|
|
r3 := newRunner(t, bookPath)
|
|
defer r3.Close()
|
|
r3.Resnapshot = true
|
|
if _, err := r3.TranslateBook(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.count() <= callsAfterRun1 {
|
|
t.Fatal("resnapshot run must re-call the provider for the re-translation")
|
|
}
|
|
}
|