415 lines
19 KiB
Go
415 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"textmachine/backend/internal/pipeline"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// render.go: the human/JSON output renderers of the commands, extracted from main.go into
|
||
// pure functions over the pipeline/store structs (package #4). Every output byte is a
|
||
// FROZEN contract (stable enums in --json — D12; the human tables are an audit
|
||
// surface D20.4): fmt.Printf was replaced with fmt.Fprintf(w, …) with
|
||
// w=os.Stdout — byte-for-byte; every deliberate change is listed in
|
||
// PROGRESS (one in this package: report carries ch/chunk/model_requested/err —
|
||
// the post-mortem of a failed call used to be an "empty string", the pain of a smoke run).
|
||
// The ledger callbacks preserve the EXACT order "print → read SpentUSD → print"
|
||
// of the original code: hoisting the read above the render would change the partial output on error.
|
||
|
||
// renderTranslate prints the per-chunk translation report and returns the
|
||
// CompletedWithFlags sentinel when chunks were flagged (exit 2).
|
||
func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
|
||
for _, ch := range res.Chunks {
|
||
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
|
||
switch {
|
||
case ch.Disposition == pipeline.DispOK:
|
||
fmt.Fprintln(w, ch.FinalText)
|
||
case ch.FinalText != "":
|
||
// Cosmetic sanitizer strip (D35.4a): the leak was removed and the remainder exported,
|
||
// but the chunk stays flagged for a human to verify the auto-clean — not lost to an
|
||
// empty placeholder (ch5/ch20 chapter openers used to drop whole for a leading «###»).
|
||
fmt.Fprintf(w, "[FLAG %s — leak cleaned, exported cleaned, verify] ↓\n", ch.FlagReason)
|
||
fmt.Fprintln(w, ch.FinalText)
|
||
default:
|
||
fmt.Fprintf(w, "[FLAG %s] chunk not translated — draft/edit unusable, flagged for a human\n", ch.FlagReason)
|
||
}
|
||
for _, st := range ch.Stages {
|
||
how := "call"
|
||
switch {
|
||
case st.Disposition == pipeline.DispSkipped:
|
||
how = "skipped"
|
||
case st.FromResume:
|
||
how = "resume"
|
||
}
|
||
fmt.Fprintf(w, " %-8s %-22s %-8s %-8s%s $%.6f (cum $%.6f) in=%d (cached=%d) out=%d+%d att=%d %dms finish=%s\n",
|
||
st.Stage, st.Model, how, st.Disposition, flagSuffix(st.FlagReason),
|
||
st.CostUSD, st.CumCostUSD,
|
||
st.Usage.PromptTokens, st.Usage.CachedTokens,
|
||
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.Attempts, st.LatencyMS, st.FinishReason)
|
||
}
|
||
fmt.Fprintln(w)
|
||
}
|
||
|
||
fmt.Fprintf(w, "TOTAL (this run): $%.6f — chunks %d, flags %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
|
||
committed, reserved, err := ledger()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintf(w, "Book ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||
|
||
// Exit code 2 is a typed sentinel, not an infra error: the report above is
|
||
// already on stdout; main() maps this to a non-zero exit for the operator.
|
||
if res.Flagged > 0 {
|
||
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// renderSignatureStop prints the operator-facing bank-mining signature-stop banner (R1-FL-A): the run
|
||
// paused before the edit wave because the miner proposed N new terms for owner sign. It surfaces the term
|
||
// count + the signature-map path + the resume path (promote a term into the mined-delta file OR decline it
|
||
// in the mined-rejects file, then re-run). Distinct from a flag/crash — main() maps the sentinel to exit 3.
|
||
func renderSignatureStop(w io.Writer, s *pipeline.WaveSignatureStop) {
|
||
fmt.Fprintf(w, "=== BANK-MINING: STOP FOR SIGNATURE (%d new term(s)) ===\n", s.Terms)
|
||
fmt.Fprintf(w, "The run STOPPED before the edit wave: the miner proposed %d new term(s) for the owner to sign.\n", s.Terms)
|
||
fmt.Fprintf(w, "Signature map: %s\n", s.SignaturePath)
|
||
fmt.Fprintln(w, "Next: for EACH term either promote it into the mined-delta file (approved + dst), OR decline it in")
|
||
fmt.Fprintln(w, "the mined-rejects file (book.yaml: mined_rejects), then re-run `tmctl translate`. The stop clears")
|
||
fmt.Fprintln(w, "once every proposed term is promoted or rejected (then the delta is empty → auto-continue to the edit wave).")
|
||
}
|
||
|
||
// flagSuffix renders a non-empty flag reason as "(reason)".
|
||
func flagSuffix(r pipeline.FlagReason) string {
|
||
if r == "" {
|
||
return ""
|
||
}
|
||
return "(" + string(r) + ")"
|
||
}
|
||
|
||
// renderReport prints the request_log table, the flag/memory/style sections and
|
||
// the ledger line. Deliberate package-#4 change: the ch/chunk columns, the model with
|
||
// a fallback to the REQUESTED one (a failed call has an empty model_actual — the row was
|
||
// anonymous) and the degraded/err tail — the DB has stored these columns since Milestone 2, the printer
|
||
// dropped them, and the post-mortem needed digging into sqlite3.
|
||
//
|
||
// All three store reads are CALLBACKS, like ledger: the historical report read and
|
||
// printed interleaved, and a read failure mid-audit left the already-printed part on stdout;
|
||
// hoisting the reads above the render would silently change that partial
|
||
// output to empty (a self-review finding — the same principle documented
|
||
// below for the ledger line).
|
||
func renderReport(w io.Writer,
|
||
fetchRows func() ([]store.RequestLogView, error),
|
||
fetchFlags func() ([]store.ChunkStatus, error),
|
||
fetchStates func() ([]store.RetrievalState, error),
|
||
ledger func() (committed, reserved float64, err error)) error {
|
||
rows, err := fetchRows()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintf(w, "%-20s %-4s %-5s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s %s\n",
|
||
"ts", "ch", "chunk", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok", "err")
|
||
for _, row := range rows {
|
||
model := row.ModelActual
|
||
if model == "" && row.ModelRequested != "" {
|
||
model = row.ModelRequested + "(req)"
|
||
}
|
||
fmt.Fprintf(w, "%-20s %-4d %-5d %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d %s\n",
|
||
row.TS, row.Chapter, row.ChunkIdx, row.Stage, row.Role, model, row.PromptTokens, row.CachedTokens,
|
||
row.CacheCreationTokens, row.CompletionTokens, row.ReasoningTokens, row.CostUSD,
|
||
row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err))
|
||
}
|
||
|
||
// Flag section (Milestone 2): every chunk×stage whose disposition ≠ ok — the
|
||
// "flag for the editor" the plan requires (02-mvp Phase-1 acceptance allows N).
|
||
flags, err := fetchFlags()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
headerPrinted := false
|
||
for _, f := range flags {
|
||
if f.Disposition == "ok" {
|
||
continue
|
||
}
|
||
if !headerPrinted {
|
||
fmt.Fprintf(w, "\n=== FLAGS (disposition ≠ ok) ===\n")
|
||
fmt.Fprintf(w, "%-4s %-6s %-8s %-9s %-16s %5s %10s %s\n",
|
||
"ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail")
|
||
headerPrinted = true
|
||
}
|
||
fmt.Fprintf(w, "%-4d %-6d %-8s %-9s %-16s %5d %10.6f %s\n",
|
||
f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
|
||
}
|
||
|
||
// Memory section (step 4): the per-chunk retrieval-state, surfaced so silent glossary
|
||
// degradation is LOUD. The aggregate line always prints when a glossary is in use;
|
||
// each chunk with a post-check miss is listed (the flagger-mode signal — the model
|
||
// ignored an approved term, or we injected the wrong dst) with the offending src→dst.
|
||
states, err := fetchStates()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(states) > 0 {
|
||
var injected, sticky, ambiguous, spoiler, evicted, misses, style int
|
||
for _, rs := range states {
|
||
injected += rs.NExactHits
|
||
sticky += rs.NSticky
|
||
ambiguous += rs.NAmbiguousFlagged
|
||
spoiler += rs.NSpoilerBlocked
|
||
evicted += rs.NEvicted
|
||
misses += rs.NPostcheckMiss
|
||
style += rs.NStyleFlags
|
||
}
|
||
fmt.Fprintf(w, "\n=== MEMORY (retrieval-state) ===\n")
|
||
fmt.Fprintf(w, "injections(exact)=%d sticky=%d ambiguous=%d spoiler-blocked=%d evicted=%d post-check-misses=%d\n",
|
||
injected, sticky, ambiguous, spoiler, evicted, misses)
|
||
printed := false
|
||
for _, rs := range states {
|
||
if rs.NPostcheckMiss == 0 {
|
||
continue
|
||
}
|
||
if !printed {
|
||
fmt.Fprintf(w, "%-4s %-6s %6s %s\n", "ch", "chunk", "misses", "detail (src→dst not found in output)")
|
||
printed = true
|
||
}
|
||
fmt.Fprintf(w, "%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
|
||
}
|
||
// Cheap style/number flaggers (observability, not gates): total + per-chunk detail.
|
||
fmt.Fprintf(w, "\n=== STYLE GATES (observability: dialogue-dashes, ё, translit-interjections, 万/億 magnitudes, reflow-regression) — total %d ===\n", style)
|
||
stylePrinted := false
|
||
for _, rs := range states {
|
||
if rs.NStyleFlags == 0 {
|
||
continue
|
||
}
|
||
if !stylePrinted {
|
||
fmt.Fprintf(w, "%-4s %-6s %6s %s\n", "ch", "chunk", "flags", "detail")
|
||
stylePrinted = true
|
||
}
|
||
fmt.Fprintf(w, "%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NStyleFlags, rs.StyleDetail)
|
||
}
|
||
}
|
||
|
||
committed, reserved, err := ledger()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintf(w, "\nBook ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||
return nil
|
||
}
|
||
|
||
// errTail collapses the degraded/err columns into one bounded table tail (empty
|
||
// when the call was clean). Truncation is on a rune boundary: err carries raw chunks
|
||
// of provider bodies (CJK/Cyrillic), a byte slice would print broken UTF-8
|
||
// (a self-review finding).
|
||
func errTail(degraded, errText string) string {
|
||
s := degraded
|
||
if errText != "" {
|
||
if s != "" {
|
||
s += " "
|
||
}
|
||
s += errText
|
||
}
|
||
if len(s) > 120 {
|
||
cut := 120
|
||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||
cut--
|
||
}
|
||
s = s[:cut] + "…"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// renderQuality prints the deterministic per-run quality-report (D39 layer 5): the claim-1 structural
|
||
// KPI (mean sentences per narrative paragraph) plus the aggregated deterministic signals
|
||
// (dialogue-dash, glossary consistency, number drift, CJK-leak / echo rates, trust-gated). Pure
|
||
// observability — a summary section, never an exit code. Kept SEPARATE from renderReport so that
|
||
// function's frozen callback contract (render_test.go) is untouched.
|
||
func renderQuality(w io.Writer, q *pipeline.QualityReport) error {
|
||
fmt.Fprintf(w, "\n=== QUALITY (per-run, deterministic signals — observability, not a gate) ===\n")
|
||
fmt.Fprintf(w, "total units=%d · reached final=%d · with export text=%d\n", q.TotalUnits, q.ProcessedUnits, q.TextUnits)
|
||
// Claim-1: choppy paragraphs. ≈1.0 sentences/paragraph = choppy (the owner's complaint); higher = merged prose.
|
||
fmt.Fprintf(w, "STRUCTURE (claim-1 «choppy paragraphs»): sentences/narrative-paragraph=%.2f (sentences=%d / narrative-paragraphs=%d)\n",
|
||
q.MeanSentPerNarrPara, q.NarrativeSentences, q.NarrativeParagraphs)
|
||
fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d\n",
|
||
q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated)
|
||
// Echo split (D39.18): draft = the translator echoed (incl. dropped c-lite members) / edit = the editor
|
||
// echoed in what was delivered. Cosmetic-strip is per unit (the sanitizer runs on the final stage).
|
||
fmt.Fprintf(w, "STRIPS/ECHO: cosmetic-strip units=%d (%.1f%%, markdown+CJK) · echo draft=%d (%.1f%%) · echo edit=%d (%.1f%%)\n",
|
||
q.CosmeticStripUnits, 100*q.CosmeticStripRate, q.EchoDraftChunks, 100*q.EchoDraftRate, q.EchoEditUnits, 100*q.EchoEditRate)
|
||
return nil
|
||
}
|
||
|
||
// renderExport prints the read-only export projection (D39 layer 6 / D39.2-open №1): every chunk's
|
||
// FINAL export text EXACTLY as `tmctl translate` ships it (export-normalised), for the polygon
|
||
// extractor / reader-samples to read the same bytes the backend does instead of the raw checkpoint.
|
||
// The DEFAULT is stable JSON (the machine surface the extractor consumes); --plaintext emits the human
|
||
// concatenation (a per-chunk header banner + the text, flagged chunks marked). Pure $0 read — never an
|
||
// exit-2 sentinel (unlike translate/status): export is an audit projection, not a run verdict.
|
||
func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error {
|
||
if asPlaintext {
|
||
// Manifest/drift summary first (F3/F4): a partial or drifted book is EXPLICIT, not silently
|
||
// exported as complete.
|
||
fmt.Fprintf(w, "# export %s — total units=%d, exported=%d, pending=%d",
|
||
exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits, exp.PendingUnits)
|
||
if exp.GhostRows > 0 {
|
||
fmt.Fprintf(w, ", ghost-rows-dropped=%d", exp.GhostRows)
|
||
}
|
||
if exp.ConfigDrift {
|
||
fmt.Fprintf(w, " ⚠ CONFIG-DRIFT (the current config renders a different snapshot — a gate/stage may have changed since the run; %.12s)", exp.CurrentSnapshot)
|
||
}
|
||
fmt.Fprintln(w)
|
||
for _, ce := range exp.Chunks {
|
||
switch {
|
||
case ce.Disposition == "pending":
|
||
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — pending (not yet translated) ===\n", ce.Chapter, ce.ChunkIdx)
|
||
case ce.Disposition == string(pipeline.DispOK):
|
||
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d ===\n", ce.Chapter, ce.ChunkIdx)
|
||
case ce.FinalText != "":
|
||
// Cosmetic sanitizer strip (D35.4a): auto-cleaned remainder, flagged for a human.
|
||
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (leak cleaned, verify) ===\n",
|
||
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
|
||
default:
|
||
// Substantive flag / upstream skip: no export text (D2 — contaminated output never ships).
|
||
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (not translated, flagged for a human) ===\n",
|
||
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
|
||
}
|
||
fmt.Fprintln(w, ce.FinalText)
|
||
}
|
||
return nil
|
||
}
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(exp)
|
||
}
|
||
|
||
// flagParen renders a non-empty flag reason as " (reason)" for the plaintext export banner.
|
||
func flagParen(r string) string {
|
||
if r == "" {
|
||
return ""
|
||
}
|
||
return " (" + r + ")"
|
||
}
|
||
|
||
// renderStatusJSON emits the StatusReport as indented JSON (stable enums — the
|
||
// ratified CI/IDE contract, D12/D15.3) and maps flagged>0 to the exit-2 sentinel.
|
||
func renderStatusJSON(w io.Writer, rep *pipeline.StatusReport) error {
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(rep); err != nil {
|
||
return err
|
||
}
|
||
// Symmetric with the human mode (minor 1d): flagged>0 is exit 2 (completed-with-flags), not
|
||
// exit 0. The full JSON is already on stdout; the sentinel only sets the shell disposition
|
||
// (its message goes to stderr in main), so a CI/IDE consumer both parses the projection AND
|
||
// sees "attention needed" in the exit code instead of silently reading 0.
|
||
if rep.Flagged > 0 {
|
||
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalUnits}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// renderStatusHuman prints the operator dashboard (unit counts, per-chapter
|
||
// passports, money, secondary ETA) and maps flagged>0 to the exit-2 sentinel.
|
||
// cfgPath goes into the "what to do" hints (redrive/translate command lines).
|
||
func renderStatusHuman(w io.Writer, rep *pipeline.StatusReport, cfgPath string) error {
|
||
snap := rep.Snapshot
|
||
if snap == "" {
|
||
snap = "—"
|
||
} else if len(snap) > 12 {
|
||
snap = snap[:12]
|
||
}
|
||
drift := ""
|
||
if rep.SnapshotDrift {
|
||
drift += " ⚠ SNAPSHOT-DRIFT (several snapshots in chunk_status — the config changed; --resnapshot)"
|
||
}
|
||
if rep.ConfigDrift {
|
||
drift += " ⚠ CONFIG-DRIFT (the current config renders a different snapshot — an edit since the run; translate will require --resnapshot = re-paying for the book)"
|
||
}
|
||
fmt.Fprintf(w, "=== STATUS: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift)
|
||
fmt.Fprintf(w, "Progress: %d/%d units (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n",
|
||
rep.Done, rep.TotalUnits, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending)
|
||
fmt.Fprintf(w, "Escalations: %d · post-check misses (confirmed): %d · style-flags (observability): %d\n",
|
||
rep.Escalations, rep.PostcheckMisses, rep.StyleFlags)
|
||
ceil := ""
|
||
if rep.BookCeilingUSD > 0 {
|
||
ceil = fmt.Sprintf(" (ceiling $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
|
||
}
|
||
fmt.Fprintf(w, "Money: committed=$%.6f reserved=$%.6f%s · book forecast ~$%.6f\n",
|
||
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
|
||
if rep.ETASeconds > 0 {
|
||
fmt.Fprintf(w, "ETA: ~%.0fs (mean throughput of fresh calls, NOT EWMA — D12-deviation; secondary)\n", rep.ETASeconds)
|
||
}
|
||
|
||
if len(rep.Chapters) > 0 {
|
||
// style = cheap deterministic style/number flags (observability, not a disposition) — a per-
|
||
// chapter glance count so a human sees where the linters fired without opening `report` (D20.4).
|
||
fmt.Fprintf(w, "\n%-5s %-9s %-10s %-6s %-4s %-6s %-16s %10s\n", "ch", "done/all", "verdict", "flag", "esc", "style", "worst_flag", "cost_usd")
|
||
for _, p := range rep.Chapters {
|
||
fmt.Fprintf(w, "%-5d %d/%-7d %-10s %-6d %-4d %-6d %-16s %10.6f\n",
|
||
p.Chapter, p.UnitsDone, p.UnitsTotal, p.Verdict, p.UnitsFlagged, p.Escalations, p.StyleFlags,
|
||
dashIfEmpty(p.WorstFlagReason), p.CostUSD)
|
||
}
|
||
}
|
||
// flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a "clean run,
|
||
// attention needed" signal. Two kinds need DIFFERENT actions (minor 1d): a chunk_status flag
|
||
// (soft_refusal, length, …) is re-drivable with `tmctl redrive`; a gate-promoted glossary_miss
|
||
// is NOT (redrive is a no-op — the miss re-derives from the unchanged glossary), so it needs a
|
||
// seed fix + `translate --resnapshot`. Advise each set separately instead of one blanket hint.
|
||
if rep.Flagged > 0 {
|
||
if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 {
|
||
fmt.Fprintf(w, "\n%d flagged chunk(s) need attention — re-attack: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
|
||
redrivable, cfgPath)
|
||
}
|
||
if rep.GlossaryMissFlagged > 0 {
|
||
fmt.Fprintf(w, "\n%d chunk(s) flagged by the post-check gate (glossary_miss) — redrive is a no-op for them: fix the seed glossary (term/dst) and re-run `tmctl translate --config %s --resnapshot` (re-pays the affected chunks)\n",
|
||
rep.GlossaryMissFlagged, cfgPath)
|
||
}
|
||
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalUnits}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// dashIfEmpty renders "" as "—" for a table cell.
|
||
func dashIfEmpty(s string) string {
|
||
if s == "" {
|
||
return "—"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// renderRedrive prints the redrive plan and (after a live re-run) the re-run
|
||
// totals. ledger may return an error — it is DELIBERATELY swallowed without the ledger
|
||
// line (original behaviour: flaky reads must not change the redrive exit code).
|
||
func renderRedrive(w io.Writer, bookID string, summary *pipeline.RedriveSummary, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
|
||
fmt.Fprintf(w, "=== REDRIVE: %s ===\n", bookID)
|
||
if len(summary.Targets) == 0 {
|
||
fmt.Fprintln(w, "No flagged chunks match the selector — nothing to re-attack.")
|
||
return nil
|
||
}
|
||
fmt.Fprintf(w, "Targets: %d flagged chunk(s) — reset the terminal flag + a fresh retry/escalation budget (DispOK untouched):\n", len(summary.Targets))
|
||
for _, t := range summary.Targets {
|
||
fmt.Fprintf(w, " ch%d/chunk%d (%s) → stages: %s\n", t.Chapter, t.ChunkIdx, t.FlagReason, strings.Join(t.Stages, ", "))
|
||
}
|
||
if summary.DryRun {
|
||
fmt.Fprintln(w, "[dry-run: nothing reset, no calls made]")
|
||
return nil
|
||
}
|
||
fmt.Fprintln(w, "[reset; re-running the durable loop — DispOK chunks resume at $0, reset ones re-attack]")
|
||
fmt.Fprintln(w)
|
||
|
||
if res != nil {
|
||
fmt.Fprintf(w, "TOTAL re-run (this run): $%.6f — chunks %d, flags %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
|
||
committed, reserved, serr := ledger()
|
||
if serr == nil {
|
||
fmt.Fprintf(w, "Book ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||
}
|
||
if res.Flagged > 0 {
|
||
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
|
||
}
|
||
}
|
||
return nil
|
||
}
|