textmachine/backend/internal/pipeline/export_test.go

305 lines
12 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// export_test.go pins the read-only export surface (D39 слой 6 / D39.2-open №1): `tmctl export` must
// emit each chunk's FINAL text EXACTLY as `tmctl translate` ships it (export-normalised), for BOTH a
// fresh run and a $0 resume — the whole point being that the polygon extractor reads the SAME bytes
// the backend does, not the RAW stored checkpoint (which skips the export contract for a clean chunk).
// TestExportMatchesTranslateFinalText runs a real 3-chunk book across the three export outcomes and
// asserts Export() reproduces BookResult's FinalText byte-for-byte — and that a clean chunk carrying a
// normalize-relevant artifact is exported NORMALISED (not the raw checkpoint), which is the fix.
func TestExportMatchesTranslateFinalText(t *testing.T) {
rec := &reqRec{}
// Three chapters (\f-separated) → three 1-chunk chapters. The editor (final stage) emits:
// ch1: clean prose carrying a U+3000 indent + fullwidth digits → OK, but export must NORMALISE it;
// ch2: a sparse contentless CJK-leak run (below the echo threshold) → cosmetic sanitizer strip;
// ch3: a leaked service preamble → SUBSTANTIVE sanitizer defect → dropped (FinalText "").
const (
src1 = "ГЛАВААА"
src2 = "ГЛАВАББ"
src3 = "ГЛАВАВВ"
)
editCleanArtifact := "Судзуки шёл по коридору.  шага до двери." // U+3000 + «123»
editCosmeticLeak := "Он поднял кулак 羅漢 и ударил врага." // sparse Han run → strip
editPreamble := "Вот перевод фрагмента:\nОн молча ушёл в туман." // preamble → dropped
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
return "черновик перевода этой главы.", "stop" // any draft — clean, non-echo
}
switch {
case strings.Contains(body, src1):
return editCleanArtifact, "stop"
case strings.Contains(body, src2):
return editCosmeticLeak, "stop"
default: // src3
return editPreamble, "stop"
}
})
defer srv.Close()
// Enable the output-sanitizer so the cosmetic-strip (ch2) and substantive-drop (ch3) paths engage.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: src1 + "\f" + src2 + "\f" + src3,
gatesYAML: "gates:\n sanitizer:\n enabled: true\n",
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
res, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 3 {
t.Fatalf("want 3 chunks, got %d: %+v", len(res.Chunks), res.Chunks)
}
// The authoritative FinalText per chunk, straight from the translate result.
type want struct {
final string
disp Disposition
flag FlagReason
}
byChunk := map[[2]int]want{}
for _, ch := range res.Chunks {
byChunk[[2]int{ch.Chapter, ch.ChunkIdx}] = want{ch.FinalText, ch.Disposition, ch.FlagReason}
}
// The clean chunk MUST be export-normalised, not raw: no U+3000, no fullwidth digits survive.
ch1 := byChunk[[2]int{1, 0}]
if strings.ContainsRune(ch1.final, ' ') || strings.Contains(ch1.final, "") {
t.Fatalf("ch1 FinalText was not export-normalised: %q", ch1.final)
}
if ch1.final != "Судзуки шёл по коридору. 123 шага до двери." {
t.Fatalf("ch1 FinalText = %q, want the normalised form", ch1.final)
}
// The cosmetic-leak chunk is flagged-stripped with the Han run removed but the prose kept.
ch2 := byChunk[[2]int{2, 0}]
if ch2.disp != DispFlagged || ch2.flag != FlagSanitizerStripped || strings.Contains(ch2.final, "羅漢") || ch2.final == "" {
t.Fatalf("ch2 expected stripped-non-empty flagged export, got %+v", ch2)
}
// The preamble chunk is a substantive drop: flagged with an EMPTY export.
ch3 := byChunk[[2]int{3, 0}]
if ch3.disp != DispFlagged || ch3.final != "" {
t.Fatalf("ch3 expected substantive drop with empty export, got %+v", ch3)
}
assertExportEquals := func(tag string, r *Runner) {
t.Helper()
exp, err := r.Export(false)
if err != nil {
t.Fatalf("%s: Export: %v", tag, err)
}
if len(exp.Chunks) != 3 {
t.Fatalf("%s: want 3 export chunks, got %d", tag, len(exp.Chunks))
}
for _, ce := range exp.Chunks {
w, ok := byChunk[[2]int{ce.Chapter, ce.ChunkIdx}]
if !ok {
t.Fatalf("%s: export emitted an unknown chunk %d/%d", tag, ce.Chapter, ce.ChunkIdx)
}
if ce.FinalText != w.final {
t.Errorf("%s: ch%d/%d export text = %q, want translate FinalText %q", tag, ce.Chapter, ce.ChunkIdx, ce.FinalText, w.final)
}
if ce.Disposition != string(w.disp) || ce.FlagReason != string(w.flag) {
t.Errorf("%s: ch%d/%d export disp/flag = %s/%s, want %s/%s", tag, ce.Chapter, ce.ChunkIdx, ce.Disposition, ce.FlagReason, w.disp, w.flag)
}
}
}
assertExportEquals("fresh", r1)
r1.Close()
// Resume (a new process): the book is served from checkpoints at $0, and Export must reproduce the
// identical bytes — the derived stripped checkpoint and the raw ones both re-normalise deterministically.
callsBefore := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsBefore {
t.Fatalf("resume must not call the provider again: %d -> %d", callsBefore, rec.count())
}
if res2.TotalUSD != 0 {
t.Fatalf("resume must be $0, got %v", res2.TotalUSD)
}
assertExportEquals("resume", r2)
}
// TestExportGlossaryGateWithheld pins the chunk-level glossary post-check gate case (adversarial-review
// finding): the gate flips a chunk to flagged/glossary_miss AFTER the stage loop and never writes it to
// chunk_status, so a raw chunk_status read would report the chunk as clean ok and ship the text
// `translate` withheld. Export must re-derive the gate flag from retrieval_state (like Status) so it
// matches translate byte-for-byte: flagged, glossary_miss, EMPTY export.
func TestExportGlossaryGateWithheld(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ.", "stop" // final text drops the approved name → confirmed miss
}
return "Некто пошёл в библиотеку.", "stop"
})
defer srv.Close()
// Opt-in hard gate + a seed whose approved 鈴木→Судзуки is missed in the final.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
// Precondition: translate withheld this chunk (flagged glossary_miss, empty text).
if len(res.Chunks) != 1 || res.Chunks[0].FlagReason != FlagGlossaryMiss || res.Chunks[0].FinalText != "" {
t.Fatalf("precondition: want 1 chunk flagged glossary_miss with empty text, got %+v", res.Chunks)
}
exp, err := r.Export(false)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(exp.Chunks) != 1 {
t.Fatalf("want 1 export chunk, got %d", len(exp.Chunks))
}
ce := exp.Chunks[0]
// Export MUST match translate — NOT ship the withheld text as clean ok.
if ce.Disposition != string(DispFlagged) || ce.FlagReason != string(FlagGlossaryMiss) || ce.FinalText != "" {
t.Fatalf("export leaked a gate-withheld chunk: disp=%s reason=%s final=%q (want flagged/glossary_miss/\"\")",
ce.Disposition, ce.FlagReason, ce.FinalText)
}
}
// TestExportManifestPending pins F4: a PARTIAL book (a ceiling stopped the run mid-way) exports the
// untranslated chunks as explicit "pending" rows against the $0 manifest — not silently as a complete
// book (the D37-skew the polygon extractor must not inherit). Chunks is non-nil.
func TestExportManifestPending(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
// Three chapters; a book ceiling that pays for ~one chapter (2 calls ≈ $0.0036) then denies.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: "ГЛАВАА\fГЛАВАБ\fГЛАВАВ", regenerate: 0, bookUSD: 0.005,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
// The ceiling stop is an infra error — expected; we only need the partial store state it leaves.
_, _ = r.TranslateBook(ctx)
exp, err := r.Export(false)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exp.TotalChunks != 3 {
t.Fatalf("manifest must be 3 chunks, got total=%d", exp.TotalChunks)
}
if exp.PendingChunks < 1 {
t.Fatalf("a ceiling-stopped book must have pending chunks, got pending=%d (%+v)", exp.PendingChunks, exp.Chunks)
}
if len(exp.Chunks) != exp.TotalChunks {
t.Fatalf("every manifest chunk must have a row: len=%d total=%d", len(exp.Chunks), exp.TotalChunks)
}
var pending int
for _, ce := range exp.Chunks {
if ce.Disposition == "pending" {
pending++
if ce.FinalText != "" {
t.Errorf("a pending chunk must export no text: %+v", ce)
}
}
}
if pending != exp.PendingChunks {
t.Errorf("pending rows (%d) must match the counter (%d)", pending, exp.PendingChunks)
}
}
// TestExportDetectsConfigDrift pins F3: after a wire-affecting config edit (a prompt_version bump) since
// the run, Export flags ConfigDrift — the gate/stage re-derivation may not match what translate did.
func TestExportDetectsConfigDrift(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Unchanged config → no drift.
r2 := newRunner(t, bookPath)
exp, err := r2.Export(false)
if err != nil {
t.Fatal(err)
}
if exp.ConfigDrift {
t.Fatalf("an unchanged config must not drift (current=%s)", exp.CurrentSnapshot)
}
r2.Close()
// Bump a wire-affecting field → the current config renders a new snapshot → drift.
dir := filepath.Dir(bookPath)
body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
if err != nil {
t.Fatal(err)
}
changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test2", 1)
if changed == string(body) {
t.Fatal("setup: prompt_version token not found")
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)
r3 := newRunner(t, bookPath)
defer r3.Close()
exp3, err := r3.Export(false)
if err != nil {
t.Fatal(err)
}
if !exp3.ConfigDrift || exp3.CurrentSnapshot == "" {
t.Errorf("a prompt_version bump since the run must surface as ConfigDrift: %+v", exp3)
}
}
// TestExportFailLoudOnInconsistentStore pins F9: a DispOK final row with an EMPTY final_hash is an
// inconsistent store; Export fails LOUD rather than silently exporting "".
func TestExportFailLoudOnInconsistentStore(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
// Corrupt the final-stage row: DispOK but no final_hash (simulating store inconsistency).
lastStage := r.Pipeline.Stages[len(r.Pipeline.Stages)-1].Name
ch := res.Chunks[0]
cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, lastStage)
if err != nil || cs == nil {
t.Fatalf("read final row: %v cs=%v", err, cs)
}
cs.FinalHash = ""
if err := r.Store.UpsertChunkStatus(*cs); err != nil {
t.Fatal(err)
}
if _, err := r.Export(false); err == nil {
t.Fatal("Export must fail loud on a DispOK row with an empty final_hash")
} else if !strings.Contains(err.Error(), "empty final_hash") {
t.Fatalf("unexpected error: %v", err)
}
}