225 lines
8.8 KiB
Go
225 lines
8.8 KiB
Go
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")
|
||
}
|