package pipeline import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strings" "sync" "unicode" "textmachine/backend/internal/chunk" "textmachine/backend/internal/config" "textmachine/backend/internal/store" "textmachine/backend/internal/terminology" "textmachine/backend/internal/text" ) // banknote.go: the banknote-v1 in-band footnote channel (WS4, plan §4 / research/20 §B3 / exp16 // banknote.py — RATIFIED D39.10 as the dst-delivery channel). The translator, AFTER the translation, // MAY emit a versioned separator + tab-delimited term lines for NEW terms only (not in the injected // glossary). This file is the pure, deterministic PARSER + SLICER — the wire-invariant core; the // integration seams (slice BEFORE classifyOutput, derived checkpoint, snapshot fold, telemetry — // plan §4(а) 12 points) consume these functions. A faithful Go↔Python port (banknote.py is the // reference): same separator, same tolerant field split, same truncation tolerance. The src rule // DIVERGES deliberately (v2, backlog 19): attested-in-source instead of the reference's Han range. Pin (ws4_banknote_verify.py): re-parse of 95 saved lines = 0 parse_fail / 0 truncated. // bankSeparator marks the start of the banknote block. Chosen to (a) not occur in natural prose and // (b) NOT be a "Примечание/Сноска/Комментарий" trailing-note word the sanitizer reserves (§B3-1), so // the two channels never collide. Changing it is a wire/parser change → a loud --resnapshot (§4в). const bankSeparator = "⟦TM-BANK-v1⟧" // bankMaxLines is the per-chunk banknote line budget (§B3-5, feeds the max_tokens sizing at // integration point 5). The parser does not enforce it (a model over-emitting is tolerated and // merely over-counted); it bounds the prompt-side budget. // // RAISED 12 → 20 (pack-20, on the mini-run measurement D39.41 re-read): 2 of the probe's 9 blocks came // back at exactly 12 lines — a cap a fifth of the blocks press against is not a budget, it is a silent // truncation of the WHAT channel on precisely the densest chapters, which are the ones with the most new // terms to declare. Raising it costs only reserved max_tokens (billing is by ACTUAL usage), and the // reservation is now derived rather than guessed — see bankTokenBudget. const bankMaxLines = 20 // The channel has TWO versioned algorithms, and only one of them can change a paid artifact. Splitting // them (cold-run session) is what makes the fold honest in both directions: // // - bankSliceVersion governs splitBanknote — where the block is cut off the raw answer. Its output IS // the stripped draft: a checkpoint, the editor's input, the export. FOLDED into banknoteSnap; a change // re-resolves paid text, so it must be a loud --resnapshot. // - bankParseVersion governs parseBanknote — which candidate lines are accepted. Its output is EVIDENCE // for the owner's signature, never a checkpoint and never a wire byte; every run recomputes it from // the stored answers (bankObservedForBook). NOT folded, logged with the run — the same contract // gates.terminology and gates.voice already carry, and for the same reason. // // Folding the parse rule (as a single parser_version did) would re-bill a whole draft wave for a change // that cannot alter one byte of what was bought — which is a standing incentive to leave the rule wrong. const ( bankSliceVersion = "banknote-slice-v1" // v2 (backlog 19 / D39.53 default): the src rule stopped being "contains a Han ideograph" (a language // FAMILY hardcoded in the engine) and became "occurs in the book"; v2.1 adds the column-order recovery // that same invariant makes possible. bankParseVersion = "banknote-parse-v2.1-src-attested+column-order" ) // bankDerivedNS is the derived-checkpoint id NAMESPACE (§4б EXACT formula) — the "tm--v1" prefix // convention the sibling commitSanitizedExport uses ("tm-sanitized-v1"), kept DISTINCT from the parser // version above exactly as sanitizerSnap.Version (sanitizer-v6) is distinct from the "tm-sanitized-v1" // namespace: it embeds the channel version so a version change yields a fresh (never colliding) id. const bankDerivedNS = "tm-banknote-v1" // bankTypeOK is the accepted type set; anything else falls back to "term" (a benign default). var bankTypeOK = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true} // bankFieldSplit tolerates a tab, a run of ≥2 spaces, or a pipe (optionally whitespace-padded) as the // field delimiter — the exact tolerance of banknote.py so a model that emits spaces instead of a real // TAB still parses. (Go \s is ASCII-whitespace; banknote lines are tab/space-delimited, so the parity // with Python's unicode \s around the pipe is exact on the term corpus.) var bankFieldSplit = regexp.MustCompile(`\t| {2,}|\s*\|\s*`) // bankEntry is one parsed candidate line (evidence for the bank-mining stop (§C); NOT written to the store until owner // signoff). Dst is the model's proposed translation — the direct dst delivery the co-occurrence // miner could not extract (蛊→гу, non-seed 龙公→Лун Гун). type bankEntry struct { Src string Dst string Type string } // bankFlags is the per-chunk telemetry surfaced loud at integration point 10. type bankFlags struct { NLines int // accepted (well-formed) banknote lines ParseFail bool // any malformed residual line (src not in the book / <2 fields) that was NOT a tolerated truncation Truncated bool // the LAST line was cut by generation length (tolerated, not a parse fail) } // splitBanknote slices the banknote block off the raw model output BEFORE any gate/editor (integration // point 1). Returns (clean_translation, raw_block). No separator → the whole (right-trimmed) output is // the clean translation and the block is "". Faithful to banknote.py: clean = output[:idx].rstrip(), // block = output[idx+len(SEP):].strip("\n"). func splitBanknote(output string) (clean, block string, malformedSep bool) { idx := strings.Index(output, bankSeparator) if idx < 0 { // S12 (§B3-1, the case that provision was written for and the code never covered): the model TRIED // to open the channel and got the marker wrong — «Примечание:», a stray space inside the brackets, a // half-typed token. The exact-match parser silently treated the whole thing as prose, so the table // travelled into the editor and the export while banknote_parse_fail stayed 0. // // The recovery keys on the engine's own distinctive fragment, which cannot occur in a translation: // the line carrying it is service output by construction, so slicing there is safe, and the run is // told LOUDLY (parse_fail) that the channel was malformed rather than absent. if at := malformedSeparatorAt(output); at >= 0 { return strings.TrimRightFunc(output[:at], unicode.IsSpace), "", true } return strings.TrimRightFunc(output, unicode.IsSpace), "", false } clean = strings.TrimRightFunc(output[:idx], unicode.IsSpace) block = strings.Trim(output[idx+len(bankSeparator):], "\n") return clean, block, false } // bankMarkerFragment is the part of the separator distinctive enough to recognise a MISSPELLED one. It is // the channel's own version token, so a translation cannot contain it by accident. const bankMarkerFragment = "TM-BANK" // malformedSeparatorAt returns the byte offset of the LINE that carries a broken separator, or -1. It is // only ever consulted when the exact separator is absent. func malformedSeparatorAt(output string) int { i := strings.Index(output, bankMarkerFragment) if i < 0 { return -1 } if nl := strings.LastIndexByte(output[:i], '\n'); nl >= 0 { return nl } return 0 } // bankSourceIndex answers the parser's src question — "is this surface actually in the book?" — for one // run. It replaces the Han-range test the Python reference used (D39.53 default, backlog 19): that rule // was a language FAMILY hardcoded in the engine, so on any non-ideographic source EVERY line was rejected // with parse_fail and the channel that carries 5/6 of the bank's candidates was dead. "Occurs in the // source" is pair-blind by construction and strictly stronger where the old rule worked at all: it also // rejects a surface the model invented, which the Han test accepted as long as it looked Chinese. // // The book side is the CONCATENATION of the chunk sources under the same normalization the miner uses // (NormalizeSourceKey), which is what makes the per-chunk fast path a pure optimisation rather than a // second rule: a chunk's normalized text is a substring of the joined text, so the two can never disagree. // The "\n" join is the boundary guard — a term cannot straddle two chunks and count as attested. type bankSourceIndex struct { book string mu sync.Mutex memo map[string]bool } // newBankSourceIndex builds the index once per run, in the composite root, so no call path can forget it. func newBankSourceIndex(chunks []chunk.Chunk) *bankSourceIndex { parts := make([]string, 0, len(chunks)) for _, ch := range chunks { parts = append(parts, text.NormalizeSourceKey(ch.Text)) } return &bankSourceIndex{book: strings.Join(parts, "\n"), memo: map[string]bool{}} } // attested reports whether a NORMALIZED key occurs in the book. Memoized because the wave workers call it // per banknote line and the book side is megabytes on a real book; the memo is a cache of a pure function, // so it changes no verdict — only the cost of repeating one. func (x *bankSourceIndex) attested(key string) bool { x.mu.Lock() defer x.mu.Unlock() if v, ok := x.memo[key]; ok { return v } v := strings.Contains(x.book, key) x.memo[key] = v return v } // bankSrcAttested builds the parser's src predicate for one parse. The chunk that produced the block is // the fast path (a banknote declares the terms of ITS fragment, so nearly every legitimate line hits it); // the book-wide index is the actual rule and the fallback for a term attested elsewhere — and for the // re-fold path, which reads stored answers and passes no chunk text. func (r *Runner) bankSrcAttested(chunkSource string) func(string) bool { nchunk := text.NormalizeSourceKey(chunkSource) return func(src string) bool { key := text.NormalizeSourceKey(strings.TrimSpace(src)) if key == "" { return false } if nchunk != "" && strings.Contains(nchunk, key) { return true } if r.bankSrc == nil { return false } return r.bankSrc.attested(key) } } // parseBanknote parses the tab-delimited block (integration seam feeding §C evidence + telemetry). // Tolerant of a TRUNCATED final line when truncatedGeneration is set (§B3-5): a short last line under // truncation is flagged banknote_truncated, not counted as a parse fail. Any other malformed line // (fewer than 2 fields, or a src NOT attested in the source) sets banknote_parse_fail. Deterministic, no // time/rand: srcAttested is a pure function of the book text (bankSourceIndex). func parseBanknote(block string, truncatedGeneration bool, srcAttested func(string) bool) ([]bankEntry, bankFlags) { var entries []bankEntry var flags bankFlags if strings.TrimSpace(block) == "" { return entries, flags } var lines []string for _, ln := range strings.Split(block, "\n") { if strings.TrimSpace(ln) != "" { lines = append(lines, ln) } } bad := 0 for i, ln := range lines { raw := bankFieldSplit.Split(strings.TrimSpace(ln), -1) var parts []string for _, p := range raw { if p = strings.TrimSpace(p); p != "" { parts = append(parts, p) } } if len(parts) < 2 { // A short LAST line under a truncated generation is a tolerated cut, not a failure. if i == len(lines)-1 && truncatedGeneration { flags.Truncated = true continue } bad++ continue } src, dst := parts[0], parts[1] typ := "term" if len(parts) >= 3 { typ = strings.ToLower(parts[2]) } if !bankTypeOK[typ] { typ = "term" } // The src rule: a bank line must name something the BOOK contains. A surface no chunk of the source // carries is either a hallucination or a mis-split line — not evidence about this book, and letting // it through would put an invented term on the owner's sign map. // // The same invariant IDENTIFIES the source column, so a model that emits the pair in the other order // is recovered instead of discarded (measured: one chunk emitted all 12 of its lines reversed, // including a term the owner had signed by hand). Order is decided, never guessed: the declared // order wins whenever it is attested, the reversal is taken only when it is the ONLY reading the // book supports, and a line neither reading supports is still a parse fail. switch { case srcAttested == nil: bad++ continue case srcAttested(src): // declared order — including the ambiguous case where both sides are attested case srcAttested(dst): src, dst = dst, src default: bad++ continue } entries = append(entries, bankEntry{Src: src, Dst: dst, Type: typ}) } flags.NLines = len(entries) flags.ParseFail = bad > 0 return entries, flags } // --- integration seam (plan §4(а) 12 points) ---------------------------------------------------- // bankTokenBudget is the extra max_tokens the translator draft reserves for the footnote block // (integration point 5): the translation comes FIRST and the block LAST, so a length cut hits the block // (tolerated), not the translation. Added ONLY when the channel is enabled. // // DERIVED, not guessed (pack-20, closing the D39.41 acceptance finding). The old value was // `bankMaxLines * 12` — a round number nobody had checked. Measured with the engine's OWN estimator over // the probe's real blocks it was 142.6/147.2 est-tokens against a budget of 144: the reservation was at // best exactly the need and at worst already negative, which is how a "generous" budget silently truncates // the channel. It is now computed from a WORST-CASE line under the same EstimateTokens the rest of the // max_tokens arithmetic uses (CJK ≈ 1 token/char, other ≈ 1/3), so a change to either the line format or // the estimator moves it automatically instead of leaving a stale literal behind. // // Over-reserving is the safe direction and nearly free: max_tokens is a CEILING and a reservation, while // billing is by ACTUAL usage — an unused ceiling costs nothing but headroom in the spend estimate. var bankTokenBudget = bankMaxLines*bankWorstLineTokens + EstimateTokens(bankSeparator+"\n") // bankWorstLineTokens is one banknote line's worst-case cost under EstimateTokens: a long source surface // (all CJK, 1 token each), a long target rendering (Cyrillic, ≈1/3), and the type column with its tabs. // The literal below IS the arithmetic — it is measured, not asserted (see TestBankTokenBudgetIsDerived). var bankWorstLineTokens = EstimateTokens(strings.Repeat("蛊", 8) + "\t" + strings.Repeat("я", 40) + "\tnickname\n") // applyBanknote slices the banknote block off a translator draft (integration points 1–5, 8). It runs // ONLY on the translator role with the channel enabled — the editor never emits banknotes, and a normal // draft has no separator (the slice is then a no-op that returns the raw text unchanged, so a // banknote-OFF run is byte-identical). When a ⟦TM-BANK-v1⟧ block IS present it returns the CLEANED // translation (what classify/coverage/echo/editor/export all see — points 2/3/4) plus that same cleaned // text as the `stripped` export to commit as a derived checkpoint (point 8), and the per-chunk telemetry // (point 10). Candidates are PARSED/accepted only under finish=="stop" (the finish=stop-only gate §4б — // a truncated block is not trusted); the slice itself runs regardless, so the length/echo classify never // sees the footnote's Han src column. Deterministic (a pure function of the raw text + gate state). func (r *Runner) applyBanknote(role, rawText, finish, chunkSource string) (clean, stripped string, flags bankFlags) { clean, stripped, flags, _ = r.applyBanknoteWithEntries(role, rawText, finish, chunkSource) return clean, stripped, flags } // applyBanknoteWithEntries is applyBanknote plus the PARSED entries — the WHAT itself. // // Until the mini-run of 25.07 the entries were dropped into `_` here (D39.36): the model proposed a // rendering for every new term, the parser accepted it, and the code kept only the counters. The owner // then met the bank-mining stop holding bare source terms and had to invent the Russian himself — the // opposite of the ratified design ("the dst is the owner's to attach via the banknote at sign time", // mining.go:20). Returning them costs nothing: the parse already ran. // // They stay OUT of the bank. A proposal is evidence for the SIGNATURE, never a canon: it reaches the // owner's sign map as `status: auto` and becomes a term only when the owner signs it. That boundary is // what keeps "the model suggested it" from silently becoming "the book uses it". func (r *Runner) applyBanknoteWithEntries(role, rawText, finish, chunkSource string) (clean, stripped string, flags bankFlags, entries []bankEntry) { if role != roleTranslator { return rawText, "", bankFlags{}, nil // the editor never emits banknotes } // THE SLICE IS UNCONDITIONAL (pack-20, D39.42 п.4). It used to be gated on Gates.Banknote.Enabled, and // that gate is what made the literal reading of "move the block into translator.md" unsafe: the key is // in no shipping config, so a prompt that started asking for the block would have had its raw // ⟦TM-BANK-v1⟧ table travel into the draft, the editor and the EXPORT with nothing to catch it (the // output sanitizer is final-stage-only). Making the slice a property of the TEXT rather than of a // config key removes that failure mode entirely — and it is byte-identical for every existing run, // because a draft with no separator takes the same early return it always did. // // The gate itself stays (owner: «булев остаётся»): it governs the max_tokens reservation and the // snapshot fold — the two things that ARE wire axes. cleanText, block, malformedSep := splitBanknote(rawText) if malformedSep { // A broken separator: the table is cut off the draft anyway (that line is service output), and the // run is told loudly. No candidates are taken from it — a block we could not even open is not // evidence. return cleanText, cleanText, bankFlags{ParseFail: true}, nil } if block == "" { return rawText, "", bankFlags{}, nil // no separator → no strip, no derived checkpoint (byte-identical path) } if finish == "stop" { // finish=stop-only gate: accept candidates + count telemetry only for a complete generation. // truncatedGeneration=false is unreachable-otherwise in prod (a truncated gen is finish≠stop). entries, flags = parseBanknote(block, false, r.bankSrcAttested(chunkSource)) } return cleanText, cleanText, flags, entries } // bankProposal is one durable WHAT proposal as it travels from the draft to the signature map: the // normalized source key (the miner's candidate space, so the join needs no fuzzy matching), the source // surface as the model wrote it, the proposed rendering and the type. type bankProposal struct { SrcKey string `json:"k"` Src string `json:"s"` Dst string `json:"d"` Type string `json:"t"` } // bankProposalsOf keys a chunk's parsed entries. Deterministic: the parser's line order is preserved. func bankProposalsOf(entries []bankEntry) []bankProposal { out := make([]bankProposal, 0, len(entries)) for _, e := range entries { key := text.NormalizeSourceKey(e.Src) if key == "" || strings.TrimSpace(e.Dst) == "" { continue // a proposal with no key or no rendering carries nothing to sign } out = append(out, bankProposal{SrcKey: key, Src: e.Src, Dst: strings.TrimSpace(e.Dst), Type: e.Type}) } return out } // bankProposalsJSON serializes them for retrieval_state.banknote_detail. Empty → "" (the column stays // empty on every channel-off chunk, so a banknote-free book's row bytes are unchanged). func bankProposalsJSON(entries []bankEntry) string { out := bankProposalsOf(entries) if len(out) == 0 { return "" } b, err := json.Marshal(out) if err != nil { return "" } return string(b) } // bankFold accumulates the draft side of the bank over every sampling of the book that exists. It is the // one place a proposal becomes evidence, so the three disciplines apply here: the enclosure trim, the // answer-language screen, and per-CHUNK counting. type bankFold struct { target *unicode.RangeTable // nil → the language screen is inert votes map[string]*bankAgg seen map[bankVote]bool OffLanguage int } type bankAgg struct { src string typ string byDst map[string]*terminology.Proposal } // bankVote is one chunk's vote for one rendering, so a count means "how many chunks proposed this" and // not "how many stored rows mention it" — they diverge the moment a chunk is re-drafted. type bankVote struct { chapter, chunk int key, dst string } func newBankFold(target *unicode.RangeTable) *bankFold { return &bankFold{target: target, votes: map[string]*bankAgg{}, seen: map[bankVote]bool{}} } // add folds one chunk's parsed proposals; src/dst arrive as the model wrote them. func (f *bankFold) add(chapter, chunk int, props []bankProposal) { for _, p := range props { // Key and displayed surface are trimmed together: a decorated surface beside a bare key would be // one thing described two ways, and no matcher will ever see the decoration in the source. key, src := text.TrimEnclosure(p.SrcKey), text.TrimEnclosure(p.Src) if key == "" || strings.TrimSpace(p.Dst) == "" { continue } if terminology.OffLanguage(p.Dst, f.target) { f.OffLanguage++ continue } v := bankVote{chapter, chunk, key, p.Dst} if f.seen[v] { continue } f.seen[v] = true a := f.votes[key] if a == nil { a = &bankAgg{src: src, typ: p.Type, byDst: map[string]*terminology.Proposal{}} f.votes[key] = a } if cur := a.byDst[p.Dst]; cur != nil { cur.Chunks++ continue } a.byDst[p.Dst] = &terminology.Proposal{Dst: p.Dst, Type: p.Type, Chunks: 1} } } // observed materializes the fold: key-ordered, each key's renderings most-proposed-first with ties broken // on the rendering itself — never on map order. func (f *bankFold) observed() []terminology.Observed { keys := make([]string, 0, len(f.votes)) for k := range f.votes { keys = append(keys, k) } sort.Strings(keys) out := make([]terminology.Observed, 0, len(keys)) for _, key := range keys { a := f.votes[key] list := make([]terminology.Proposal, 0, len(a.byDst)) for _, p := range a.byDst { list = append(list, *p) } sort.Slice(list, func(i, j int) bool { if list[i].Chunks != list[j].Chunks { return list[i].Chunks > list[j].Chunks } return list[i].Dst < list[j].Dst }) out = append(out, terminology.Observed{Key: key, Src: a.src, Type: a.typ, Proposals: list}) } return out } // bankObservedByKey folds the durable per-chunk banknote rows into the draft-side view of each source // surface. Both consumers read it: the signature-map join (proposalsFromObserved) and the terminologist's // merge, which also needs the SURFACE so a proposal for a term the miner never found is not lost. func bankObservedByKey(states []store.RetrievalState, target *unicode.RangeTable) []terminology.Observed { f := newBankFold(target) addRetrievalStates(f, states) return f.observed() } // bankObservedForBook is the draft side over EVERY sampling of the book that exists: the per-chunk // telemetry rows plus every stored translator answer, including the ones a later run superseded. Both // sources are folded into one accumulator, and votes are counted per chunk, so a chunk present in both // (the normal case) counts once and the union only ever grows when new drafts were actually bought. // // Superseded attempts are included deliberately: a draft rejected for a defect in its TEXT does not make // the terms it declared untrue, and nothing here enters the bank without a signature anyway. func (r *Runner) bankObservedForBook() ([]terminology.Observed, int, error) { f := newBankFold(r.targetScript) states, err := r.Store.RetrievalStatesForBook(r.Book.BookID) if err != nil { return nil, 0, err } addRetrievalStates(f, states) // Only answers that carry the channel's own marker can contribute, and a book's answer history is // megabytes: the marker fragment (which a malformed separator still contains) bounds the read. answers, err := r.Store.RoleResponsesForBook(r.Book.BookID, roleTranslator, bankMarkerFragment) if err != nil { return nil, 0, err } for _, a := range answers { // The same slice/parse the live path runs, so the trust rule (a complete generation only) and the // derived $0 rows are handled by one definition rather than by a second copy here. No chunk text is // passed: this path has only the stored answer, so the src rule resolves against the whole book — // the same verdict the live path reaches, since the chunk check is a subset of it. _, _, _, entries := r.applyBanknoteWithEntries(roleTranslator, a.ResponseText, a.FinishReason, "") f.add(a.Chapter, a.ChunkIdx, bankProposalsOf(entries)) } return f.observed(), f.OffLanguage, nil } func addRetrievalStates(f *bankFold, states []store.RetrievalState) { for _, rs := range states { if rs.BanknoteDetail == "" { continue } var props []bankProposal if json.Unmarshal([]byte(rs.BanknoteDetail), &props) != nil { continue // a corrupt detail blob is observability, never a reason to fail a run } f.add(rs.Chapter, rs.ChunkIdx, props) } } // bankDerivedHash is the CONTENT-ADDRESSED id of a banknote-stripped export checkpoint (§4б EXACT // formula, mirroring commitSanitizedExport's namespacing): sha256("tm-banknote-v1\x00"+reqHash+"\x00"+ // stripped), prefixed "tm-banknote-v1:" so it can never collide with a real hex attempt hash and a // resume re-derives the identical id for free. The namespace embeds the channel version → a version // change yields a new id. func bankDerivedHash(reqHash, stripped string) string { sum := sha256.Sum256([]byte(bankDerivedNS + "\x00" + reqHash + "\x00" + stripped)) return bankDerivedNS + ":" + hex.EncodeToString(sum[:]) } // commitBanknoteExport persists the banknote-stripped CLEAN draft as a $0 derived checkpoint and returns // its request_hash (→ chunk_status.final_hash), so the OK-path final_hash→checkpoint export contract // (and the editor's resume read) yields the CLEANED draft instead of the raw one carrying the footnote // (integration point 8). Mirrors commitSanitizedExport: cost 0, escalation 0, idempotent, written BEFORE // chunk_status references it. `att` is the terminal (possibly escalated) OK attempt whose block was // sliced; att.bankStripped is the cleaned text. func (r *Runner) commitBanknoteExport(st config.Stage, ch chunk.Chunk, job *store.Job, att stageAttempt) (string, error) { derivedHash := bankDerivedHash(att.reqHash, att.bankStripped) if err := r.Store.PutDerivedCheckpoint(store.Checkpoint{ RequestHash: derivedHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: att.attempt, Stage: st.Name, Role: st.Role, ModelRequested: att.modelActual, ModelActual: att.modelActual, ResponseText: att.bankStripped, UsageJSON: "{}", FinishReason: "banknote_export", }); err != nil { return "", fmt.Errorf("pipeline: commit banknote export ch%d/chunk%d/%s: %w", ch.Chapter, ch.ChunkIdx, st.Name, err) } return derivedHash, nil }