package pipeline import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/obs" "textmachine/backend/internal/store" ) // minirun_fixes_test.go: the two seams the 25.07 mini-run proved were producing a fact and losing it. // Both are driven end-to-end through the real runner (norm 11) — a unit test on the parser would have // passed happily before either fix, because neither defect was in the parsing. // --- Д1: an echo the escalation recovered must still be visible as an echo --------------------- // echoThenFixProvider makes the PRIMARY draft echo the CJK source (the D18 mine) and the escalation hop // answer properly — the exact shape the mini-run hit on ch3/chunk0. func echoThenFixProvider(echoText string) func(body string) (string, string) { return func(body string) (string, string) { switch { case isEditBody(body): return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" case strings.Contains(body, "fake-fallback"): return "ПЕРЕВОД С ФОЛБЭКА", "stop" default: return echoText, "stop" } } } // TestRecoveredEchoStaysVisibleInTheQualityReport is the headline of Д1: before the fix the metric read // the row's VERDICT column, so a recovered echo left the row `ok` with an empty flag_reason and the // report said echo_draft=0.0% on a run whose translator had echoed. The rate watches the TRANSLATOR // (the D18 mine), not our success at papering over it. func TestRecoveredEchoStaysVisibleInTheQualityReport(t *testing.T) { rec := &reqRec{} // The echo must be CJK-dense enough for the classifier to call it an artifact, and the fixture // source is ja — so echo the source back, which is precisely what the mine does. srv := newJSONProvider(rec, echoThenFixProvider(strings.Repeat(suzukiSource, 3))) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0, }) // The fixture pipeline has no escalate_to; add one so the hop can recover the chunk. addDraftEscalation(t, bookPath) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft") if err != nil || cs == nil { t.Fatalf("draft row: %v %v", cs, err) } if cs.Disposition != string(DispOK) { t.Fatalf("setup: the hop must have recovered the chunk, got %s/%s", cs.Disposition, cs.FlagReason) } if cs.FirstFlagReason != string(FlagCJKArtifact) { t.Fatalf("the recovered row must remember WHAT the primary attempt did, got first_flag_reason=%q", cs.FirstFlagReason) } q, err := r.QualityReport() if err != nil { t.Fatal(err) } if q.EchoDraftChunks != 1 { t.Errorf("echo_draft_chunks = %d, want 1 — the translator echoed, whatever we did about it", q.EchoDraftChunks) } if q.EchoDraftRecovered != 1 { t.Errorf("echo_draft_recovered = %d, want 1 — the split is what keeps the number from reading as shipped breakage", q.EchoDraftRecovered) } if q.EchoDraftRate == 0 { t.Error("echo_draft_rate must be non-zero when an echo happened") } } // TestUnrecoveredFlagIsNotDoubleCounted guards the other direction: a row whose verdict IS the echo // must not also carry it in first_flag_reason, or every genuinely-flagged chunk counts twice. func TestUnrecoveredFlagIsNotDoubleCounted(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return strings.Repeat(suzukiSource, 3), "stop" // echoes, and no hop is configured to fix it }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft") if err != nil || cs == nil { t.Fatalf("draft row: %v %v", cs, err) } if cs.FlagReason != string(FlagCJKArtifact) { t.Fatalf("setup: the row must stay flagged, got %q", cs.FlagReason) } if cs.FirstFlagReason != "" { t.Errorf("a row whose VERDICT is the echo must not repeat it in first_flag_reason (double count), got %q", cs.FirstFlagReason) } q, err := r.QualityReport() if err != nil { t.Fatal(err) } if q.EchoDraftChunks != 1 { t.Errorf("echo_draft_chunks = %d, want 1 (counted ONCE)", q.EchoDraftChunks) } if q.EchoDraftRecovered != 0 { t.Errorf("echo_draft_recovered = %d, want 0 — nothing recovered it", q.EchoDraftRecovered) } } // --- Д4: the WHAT the banknote delivered must reach the owner's signature map ------------------- // TestBanknoteProposalsArePersistedAndJoined is the D39.36 fix end to end: the translator proposes a // rendering, the parser accepts it, and it must survive to the row the mining stop reads. Before the // fix the parse result went into `_` and the owner met the stop holding bare source terms. func TestBanknoteProposalsArePersistedAndJoined(t *testing.T) { rec := &reqRec{} // A draft carrying a banknote block: two proposals, one of them repeated with a different rendering // so the vote/alternatives path is exercised too. draftWithBank := "ПЕРЕВОД ЧАНКА.\n" + bankSeparator + "\n" + "鈴木\tСудзуки\tname\n" + "図書館\tбиблиотека\tplace\n" srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return draftWithBank, "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0, banknote: true, }) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("retrieval_state: %v %v", rs, err) } if rs.NBanknoteLines != 2 { t.Fatalf("setup: the parser must have accepted 2 lines, got %d", rs.NBanknoteLines) } if rs.BanknoteDetail == "" { t.Fatal("the PARSED entries must be durable — counters alone are what D39.36 found useless") } var props []bankProposal if err := json.Unmarshal([]byte(rs.BanknoteDetail), &props); err != nil { t.Fatalf("banknote_detail must be readable JSON: %v (%s)", err, rs.BanknoteDetail) } if len(props) != 2 { t.Fatalf("want 2 stored proposals, got %d: %s", len(props), rs.BanknoteDetail) } byKey := proposalsFromObserved(bankObservedByKey([]store.RetrievalState{*rs}, nil)) if got := byKey[props[0].SrcKey]; len(got) == 0 || got[0].Dst != props[0].Dst { t.Fatalf("the fold must key proposals by the miner's normalized surface, got %+v", byKey) } } // TestBanknoteOffLeavesTheDetailEmpty pins that a channel-off book's row is unchanged — the fix must be // invisible to every book that does not use the channel. func TestBanknoteOffLeavesTheDetailEmpty(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("retrieval_state: %v %v", rs, err) } if rs.BanknoteDetail != "" { t.Errorf("a banknote-off book must store no proposals, got %q", rs.BanknoteDetail) } } // addDraftEscalation gives the fixture project a single-hop fallback on the draft stage: the hop model // in models.yaml, the stage's escalate_to, and the budget that arms it (escalation is inert at budget 0 // — the shipping default). func addDraftEscalation(t *testing.T, bookPath string) { t.Helper() dir := filepath.Dir(bookPath) modelsPath := filepath.Join(dir, "models.yaml") models, err := os.ReadFile(modelsPath) if err != nil { t.Fatal(err) } writeFile(t, modelsPath, string(models)+` fake-fallback: provider: fake price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } `) pipePath := filepath.Join(dir, "pipeline.yaml") pipe, err := os.ReadFile(pipePath) if err != nil { t.Fatal(err) } withHop := strings.Replace(string(pipe), `reasoning: "off" }`, `reasoning: "off", escalate_to: fake-fallback }`, 1) if withHop == string(pipe) { t.Fatal("setup: the draft stage line was not found in the fixture pipeline") } writeFile(t, pipePath, withHop+"\nescalation: { budget_usd: 0.5 }\n") }