textmachine/backend/internal/pipeline/banknote_integration_test.go

249 lines
11 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"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// banknote_integration_test.go: the WS4 (д) end-to-end pins for the banknote channel wired into the
// runner (banknote ON), covering the load-bearing integration points §4(а): the block is SLICED before
// the editor/gates (points 1-4), the cleaned draft is committed as a derived export and final_hash is
// re-pointed (point 8), resume re-serves the cleaned draft at $0 (point 7), the echo/length classify
// runs over the CLEANED text (point 3), the telemetry is persisted (point 10), and banknoteSnap folds
// into the snapshot only when enabled (point 6 / §4в). The banknote-OFF golden fixture stays byte-stable
// (the TestGolden guard), so these ON-only cells live here rather than in the shared golden.
// bankBlock is a well-formed 2-line banknote block a translator emits after a clean RU translation.
const bankBlock = bankSeparator + "\n龙公\tЛун Гун\tname\n蛊\tгу\tterm"
func TestBanknoteSliceReTelemetryResume(t *testing.T) {
rec := &reqRec{}
const cleanDraft = "Тихое утро в библиотеке."
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
// The editor MUST receive the CLEANED draft — the block was sliced off upstream (point 8).
if strings.Contains(body, bankSeparator) || strings.Contains(body, "龙公") {
t.Errorf("editor request leaked the banknote block:\n%s", body)
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return cleanDraft + "\n" + bankBlock, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
ctx := context.Background()
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
ch := res1.Chunks[0]
// The draft stage's usable text is the CLEANED translation (block removed, points 1-3).
if got := ch.Stages[0].Text; got != cleanDraft {
t.Fatalf("draft text must be the cleaned translation %q, got %q", cleanDraft, got)
}
if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("final text must come from the edit over the cleaned draft, got %q", ch.FinalText)
}
// The draft chunk_status.final_hash must be RE-POINTED at the tm-banknote-v1 derived checkpoint (point 8).
cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") {
t.Fatalf("draft final_hash must be a tm-banknote-v1 derived checkpoint, got %+v", cs)
}
// The derived checkpoint holds the CLEANED draft (so export/resume yield it, not the raw block).
dcp, err := r1.Store.GetCheckpoint(cs.FinalHash)
if err != nil || dcp == nil {
t.Fatalf("derived checkpoint missing: %v", err)
}
if dcp.ResponseText != cleanDraft || dcp.FinishReason != "banknote_export" {
t.Fatalf("derived checkpoint = %q/%q, want cleaned draft / banknote_export", dcp.ResponseText, dcp.FinishReason)
}
// Telemetry (point 10): 2 accepted lines, no parse fail / truncation.
rs, _ := r1.Store.GetRetrievalState("test-book", 1, 0)
if rs == nil || rs.NBanknoteLines != 2 || rs.BanknoteParseFail != 0 || rs.BanknoteTruncated != 0 {
t.Fatalf("banknote telemetry wrong: %+v", rs)
}
callsAfterRun1 := rec.count()
r1.Close()
// Resume (new process): $0, NO new provider calls, cleaned draft re-served identically (point 7).
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("resume must not re-call the provider, calls %d -> %d", callsAfterRun1, rec.count())
}
if res2.TotalUSD != 0 || res2.Chunks[0].FinalText != res1.Chunks[0].FinalText {
t.Fatalf("resume must be $0 and byte-identical, got %+v", res2.Chunks[0])
}
// FL-2: the resume must PRESERVE the banknote telemetry, not zero it. resumeFromChunkStatus serves the
// banknote-CLEANED derived checkpoint and never re-parses the raw block, so without the carry-forward
// persistRetrievalState would overwrite NBanknoteLines 2 -> 0 on this second run. Re-read to guard it.
rs2, _ := r2.Store.GetRetrievalState("test-book", 1, 0)
if rs2 == nil || rs2.NBanknoteLines != 2 || rs2.BanknoteParseFail != 0 || rs2.BanknoteTruncated != 0 {
t.Fatalf("FL-2: resume must preserve banknote telemetry (want 2/0/0), got %+v", rs2)
}
}
// A clean RU translation followed by a Han-heavy banknote block must classify OK — the echo/CJK check
// runs over the CLEANED text (point 3). WITHOUT the slice, the block's Han src column would push
// cjk_share over the 15% threshold and false-flag cjk_artifact.
func TestBanknoteClassifyOverCleaned(t *testing.T) {
rec := &reqRec{}
// A short RU translation + a LONG Han-heavy block: raw cjk_share > 0.15, cleaned cjk_share ≈ 0.
bigBlock := bankSeparator + "\n" +
strings.Repeat("龙公\tЛун Гун\tname\n古月\tГу Юэ\tname\n方源\tФан Юань\tname\n", 3)
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ред", "stop"
}
return "Он ушёл." + "\n" + bigBlock, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
if ch.Stages[0].Disposition != DispOK {
t.Fatalf("a clean RU draft + Han banknote block must classify OK over the cleaned text, got %+v", ch.Stages[0])
}
if ch.Stages[0].Text != "Он ушёл." {
t.Fatalf("draft text must be the cleaned translation, got %q", ch.Stages[0].Text)
}
}
// Enabling the banknote channel folds banknoteSnap into the snapshot (point 6 / §4в); a banknote-OFF
// project renders a DIFFERENT snapshot (so a channel flip is a loud --resnapshot). Off omits the field
// (omitempty), on adds banknote:{enabled,parser_version,token_budget} (token_budget = FL-1: the
// wire-affecting max_tokens the channel adds, folded so editing bankMaxLines is a loud --resnapshot).
func TestBanknoteSnapshotFold(t *testing.T) {
srv := httptest.NewServer(nil)
defer srv.Close()
off := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{}))
defer off.Close()
on := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{banknote: true}))
defer on.Close()
offID, offPayload, err := off.snapshotID()
if err != nil {
t.Fatal(err)
}
onID, onPayload, err := on.snapshotID()
if err != nil {
t.Fatal(err)
}
if offID == onID {
t.Fatalf("enabling the banknote channel must move the snapshot (loud --resnapshot)")
}
if strings.Contains(offPayload, "banknote") {
t.Fatalf("a banknote-OFF snapshot must omit the field (omitempty), got: %s", offPayload)
}
if !strings.Contains(onPayload, fmt.Sprintf(`"banknote":{"enabled":true,"parser_version":"%s","token_budget":%d}`, bankParserVersion, bankTokenBudget)) {
t.Fatalf("a banknote-ON snapshot must fold {enabled,parser_version,token_budget}, got: %s", onPayload)
}
}
// A truncated banknote generation (finish=length) is a length flag (retryable) — the block is not parsed
// (finish=stop-only gate §4б), and with the tolerant parser held for robustness it never counts a parse
// fail. Here a channel-off run is unaffected; this pins that the stop-only gate does not accept a
// truncated block's candidates. With regenerate=0 the length draft flags immediately.
func TestBanknoteFinishStopOnlyGate(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ред", "stop"
}
// A truncated generation: clean text + a partial block, finish=length.
return "Незаконченный перевод" + "\n" + bankSeparator + "\n龙公\tЛун", "length"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
// finish=length → flagged length (the block is not accepted; the translation is retried in prod).
if ch.Stages[0].Disposition != DispFlagged || ch.Stages[0].FlagReason != FlagLength {
t.Fatalf("a truncated generation must flag length (stop-only gate rejects the block), got %+v", ch.Stages[0])
}
// No candidates accepted under a non-stop finish → zero telemetry.
rs, _ := r.Store.GetRetrievalState("test-book", 1, 0)
if rs != nil && rs.NBanknoteLines != 0 {
t.Fatalf("a non-stop generation must accept 0 banknote lines, got %+v", rs)
}
}
// The banknote slice + derived-commit + re-point must run AFTER the escalation resolve and consume the
// ESCALATED draft's stripped text (WS4 point 8's escalation-ordering note): the primary echoes CJK
// (cjk_artifact) → escalates → the fallback returns a clean RU translation + a banknote block, which is
// sliced and committed as a derived checkpoint keyed on the ESCALATED attempt's request_hash.
func TestBanknoteEscalatedDraftStripped(t *testing.T) {
rec := &reqRec{}
const fbClean = "Тихое утро в библиотеке."
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
if strings.Contains(body, bankSeparator) {
t.Errorf("editor leaked the escalated banknote block:\n%s", body)
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, "fake-fallback") {
return fbClean + "\n" + bankBlock, "stop" // the fallback emits a clean translation + banknote
}
return "静かな図書館の朝。", "stop" // the primary echoes the CJK source → cjk_artifact
})
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, string(raw)+"\ngates:\n banknote:\n enabled: true\n")
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
draft := ch.Stages[0]
if !draft.Escalated || draft.EscalationModel != "fake-fallback" {
t.Fatalf("the draft must have escalated to fake-fallback, got %+v", draft)
}
// The escalated draft's usable text is the CLEANED fallback translation (block sliced).
if draft.Text != fbClean {
t.Fatalf("escalated draft text must be the cleaned fallback translation %q, got %q", fbClean, draft.Text)
}
// final_hash re-pointed at a tm-banknote-v1 derived checkpoint holding the cleaned ESCALATED draft.
cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") {
t.Fatalf("escalated draft final_hash must be a derived checkpoint, got %+v", cs)
}
dcp, _ := r.Store.GetCheckpoint(cs.FinalHash)
if dcp == nil || dcp.ResponseText != fbClean {
t.Fatalf("derived checkpoint must hold the cleaned escalated draft, got %+v", dcp)
}
if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("final text must come from the edit over the cleaned escalated draft, got %q", ch.FinalText)
}
}