diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index ae3785f..bd5406a 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -4,6 +4,7 @@ package main import ( "bufio" "context" + "errors" "flag" "fmt" "os" @@ -16,8 +17,20 @@ import ( "textmachine/backend/internal/pipeline" ) +// Exit codes (Веха 2): 0 clean · 2 completed-with-flags (приёмка допускает N +// флагов) · 1 infra failure. The exit-2 case is carried up as a typed sentinel +// (*pipeline.CompletedWithFlags) so a "clean run, attention needed" never +// masquerades as an infra crash and vice-versa. func main() { - if err := run(); err != nil { + err := run() + var flagged *pipeline.CompletedWithFlags + switch { + case err == nil: + return + case errors.As(err, &flagged): + fmt.Fprintln(os.Stderr, "tmctl:", err) + os.Exit(2) + default: fmt.Fprintln(os.Stderr, "tmctl:", err) os.Exit(1) } @@ -29,7 +42,11 @@ func run() error { } cmd, args := os.Args[1], os.Args[2:] - fs := flag.NewFlagSet(cmd, flag.ExitOnError) + // ContinueOnError (not ExitOnError): the stdlib's ExitOnError calls os.Exit(2) + // on a parse failure, which would COLLIDE with exit code 2 (completed-with- + // flags). Returning the error routes a bad flag to main()'s default branch → + // exit 1, keeping 2 exclusive to the flagged-chunks case (self-review Веха 2). + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) cfgPath := fs.String("config", "", "path to book.yaml") resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (пере-перевод оплаченных чанков — явное согласие)") if err := fs.Parse(args); err != nil { @@ -69,35 +86,58 @@ func translate(ctx context.Context, cfgPath string, resnapshot bool) error { defer r.Close() r.Resnapshot = resnapshot - res, err := r.TranslateOneChunk(ctx) + res, err := r.TranslateBook(ctx) if err != nil { return err } - fmt.Println("=== ПЕРЕВОД ===") - fmt.Println(res.FinalText) - fmt.Println() - fmt.Println("=== СТОИМОСТЬ ПО СТАДИЯМ ===") - for _, st := range res.Stages { - src := "call" - if st.FromResume { - src = "checkpoint" + for _, ch := range res.Chunks { + fmt.Printf("=== ГЛАВА %d ЧАНК %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason)) + if ch.Disposition == pipeline.DispOK { + fmt.Println(ch.FinalText) + } else { + fmt.Printf("[ФЛАГ %s] чанк не переведён — черновик/редактура непригодны, помечен для человека\n", ch.FlagReason) } - fmt.Printf("%-8s %-22s %-10s $%.6f in=%d (cached=%d, cache_write=%d) out=%d+%d %dms finish=%s\n", - st.Stage, st.Model, src, st.CostUSD, - st.Usage.PromptTokens, st.Usage.CachedTokens, st.Usage.CacheCreationTokens, - st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.LatencyMS, st.FinishReason) + for _, st := range ch.Stages { + how := "call" + switch { + case st.Disposition == pipeline.DispSkipped: + how = "skipped" + case st.FromResume: + how = "resume" + } + fmt.Printf(" %-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.Println() } - fmt.Printf("ИТОГО (этот запуск): $%.6f\n", res.TotalUSD) + fmt.Printf("ИТОГО (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged) committed, reserved, err := r.Store.SpentUSD(res.BookID) if err != nil { return err } fmt.Printf("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 } +// flagSuffix renders a non-empty flag reason as "(reason)". +func flagSuffix(r pipeline.FlagReason) string { + if r == "" { + return "" + } + return "(" + string(r) + ")" +} + func report(cfgPath string) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { @@ -119,6 +159,27 @@ func report(cfgPath string) error { row.LatencyMS, row.FinishReason, row.TMHit, row.OK) } + // Flag section (Веха 2): every chunk×stage whose disposition ≠ ok — the + // "флаг редактору" the plan requires (02-mvp Фаза-1 приёмка допускает N). + flags, err := r.Store.ChunkStatusesForBook(r.Book.BookID) + if err != nil { + return err + } + headerPrinted := false + for _, f := range flags { + if f.Disposition == "ok" { + continue + } + if !headerPrinted { + fmt.Printf("\n=== ФЛАГИ (disposition ≠ ok) ===\n") + fmt.Printf("%-4s %-6s %-8s %-9s %-16s %5s %10s %s\n", + "ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail") + headerPrinted = true + } + fmt.Printf("%-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) + } + committed, reserved, err := r.Store.SpentUSD(r.Book.BookID) if err != nil { return err diff --git a/backend/internal/pipeline/chunker.go b/backend/internal/pipeline/chunker.go new file mode 100644 index 0000000..783f3e7 --- /dev/null +++ b/backend/internal/pipeline/chunker.go @@ -0,0 +1,88 @@ +package pipeline + +import "strings" + +// chunker.go: the Веха-2 segmenter SEAM. Its only job right now is to turn a +// normalized source into an ordered list of (chapter, chunk) units so the runner +// loop, resume and disposition machinery can be exercised over MANY chunks — the +// hardcoded "chapter=1/chunk=0" of Фаза 0 is gone. +// +// This is a deliberate PLACEHOLDER. The real linguistic chunker of шаг 3 +// (sentence segmentation, read-only overlap, ruby name-reading extraction for +// ja) replaces SplitChunks WITHOUT touching the loop — it only has to keep the +// Chunk contract and bump chunkerVersion. chunkerVersion is folded into the +// snapshot (render.go), so changing the segmentation is an explicit +// re-translation (a loud --resnapshot), never a silent cache miss (R6/D5.2). + +// Chunk is one ordered unit of translation work. +type Chunk struct { + Chapter int // 1-based + ChunkIdx int // 0-based within its chapter + Text string +} + +// chapterSep splits the source into chapters. Form feed (U+000C) is the ASCII +// "page/section break" — semantically a chapter boundary, invisible in prose, +// and untouched by NormalizeSource (BOM/CRLF/NFC/outer-trim leave an internal +// \f intact). A source with no \f is a single chapter (Фаза-0 backward compat: +// the one-file example stays one chapter). +const chapterSep = "\f" + +// targetChunkChars is the placeholder chunk size (≈ the plan's "1–2k tokens"; +// for a CJK source ~1 char ≈ 1 token). Paragraphs are packed up to this size and +// never split, so the 264-char example remains a single chunk. +const targetChunkChars = 1500 + +// SplitChunks segments a NORMALIZED source (post-NormalizeSource) into an ordered +// chunk list. Rule: split on \f into 1-based chapters; within a chapter pack +// blank-line paragraphs greedily into chunks of ≤ targetChunkChars, never +// splitting a paragraph (a paragraph larger than the target is its own chunk); +// drop empty pieces. Fully deterministic. +func SplitChunks(source string) []Chunk { + var out []Chunk + chapterNo := 0 + for _, chapRaw := range strings.Split(source, chapterSep) { + paras := splitParagraphs(chapRaw) + if len(paras) == 0 { + continue // an empty chapter block does not consume a chapter number + } + chapterNo++ + chunkIdx := 0 + var buf strings.Builder + flush := func() { + if buf.Len() == 0 { + return + } + out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: buf.String()}) + chunkIdx++ + buf.Reset() + } + for _, p := range paras { + // Start a new chunk if adding this paragraph would exceed the target + // and the current chunk is non-empty (never split a paragraph). + if buf.Len() > 0 && buf.Len()+len("\n\n")+len(p) > targetChunkChars { + flush() + } + if buf.Len() > 0 { + buf.WriteString("\n\n") + } + buf.WriteString(p) + } + flush() + } + return out +} + +// splitParagraphs breaks a chapter block on blank lines and trims each paragraph, +// dropping empties. Deterministic; whitespace-only paragraphs vanish. +func splitParagraphs(block string) []string { + var paras []string + for _, raw := range strings.Split(block, "\n\n") { + // Collapse a run of blank lines: a "paragraph" that is itself only blank + // lines (from 3+ consecutive newlines) trims to "". + if p := strings.TrimSpace(raw); p != "" { + paras = append(paras, p) + } + } + return paras +} diff --git a/backend/internal/pipeline/chunker_test.go b/backend/internal/pipeline/chunker_test.go new file mode 100644 index 0000000..3a2ec0a --- /dev/null +++ b/backend/internal/pipeline/chunker_test.go @@ -0,0 +1,96 @@ +package pipeline + +import ( + "reflect" + "strings" + "testing" +) + +func TestSplitChunksSingleParagraph(t *testing.T) { + got := SplitChunks("静かな図書館の朝。") + want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("single paragraph = %+v, want %+v", got, want) + } +} + +func TestSplitChunksChapters(t *testing.T) { + src := "Глава один." + chapterSep + "Глава два." + chapterSep + "Глава три." + got := SplitChunks(src) + want := []Chunk{ + {Chapter: 1, ChunkIdx: 0, Text: "Глава один."}, + {Chapter: 2, ChunkIdx: 0, Text: "Глава два."}, + {Chapter: 3, ChunkIdx: 0, Text: "Глава три."}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("chapters = %+v, want %+v", got, want) + } +} + +func TestSplitChunksPacksToTarget(t *testing.T) { + // Two paragraphs that together exceed the target must split into two chunks, + // each never splitting a paragraph. + p1 := strings.Repeat("a", 800) + p2 := strings.Repeat("b", 800) + got := SplitChunks(p1 + "\n\n" + p2) + want := []Chunk{ + {Chapter: 1, ChunkIdx: 0, Text: p1}, + {Chapter: 1, ChunkIdx: 1, Text: p2}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("packing = chapters/chunks %d, want 2 chunks; got %+v", len(got), summarize(got)) + } +} + +func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) { + // Small paragraphs pack into one chunk (under the target), joined by a blank line. + got := SplitChunks("Абзац один.\n\nАбзац два.\n\nАбзац три.") + if len(got) != 1 { + t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got)) + } + if got[0].Text != "Абзац один.\n\nАбзац два.\n\nАбзац три." { + t.Fatalf("packed text = %q", got[0].Text) + } +} + +func TestSplitChunksOversizeParagraphIsOwnChunk(t *testing.T) { + big := strings.Repeat("ы", 2000) // > targetChunkChars but never split + got := SplitChunks(big) + if len(got) != 1 || got[0].Text != big { + t.Fatalf("an oversize paragraph must be one un-split chunk, got %+v", summarize(got)) + } +} + +func TestSplitChunksDropsEmpties(t *testing.T) { + // Empty chapter blocks and whitespace-only paragraphs vanish; chapter numbers + // only advance for non-empty chapters. + src := "A" + chapterSep + " \n\n " + chapterSep + "B" + got := SplitChunks(src) + want := []Chunk{ + {Chapter: 1, ChunkIdx: 0, Text: "A"}, + {Chapter: 2, ChunkIdx: 0, Text: "B"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("empties = %+v, want %+v", got, want) + } +} + +func TestSplitChunksDeterministic(t *testing.T) { + src := "Абзац.\n\nЕщё абзац." + chapterSep + strings.Repeat("длинный ", 300) + a, b := SplitChunks(src), SplitChunks(src) + if !reflect.DeepEqual(a, b) { + t.Fatal("SplitChunks must be deterministic") + } +} + +func summarize(cs []Chunk) []string { + out := make([]string, len(cs)) + for i, c := range cs { + txt := c.Text + if len(txt) > 12 { + txt = txt[:12] + "…" + } + out[i] = strings.TrimSpace(txt) + } + return out +} diff --git a/backend/internal/pipeline/disposition.go b/backend/internal/pipeline/disposition.go new file mode 100644 index 0000000..ee505f5 --- /dev/null +++ b/backend/internal/pipeline/disposition.go @@ -0,0 +1,286 @@ +package pipeline + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "textmachine/backend/internal/llm" +) + +// disposition.go: the per-chunk×stage verdict machinery of the Веха-2 runner +// (D2). It answers three questions about a completion — is it usable, and if not +// WHY, and may we re-attack it — WITHOUT ever touching the wire (classify is a +// pure function of the completion text + finish reason + a little context, so a +// resume reproduces the same verdict from the same checkpoint, no re-billing). +// +// This is deliberately SEPARATE from the configurable coverage QA-gate +// (Gates.Coverage, шаг 6): that gate is opt-in and threshold-driven; the +// classifier here is intrinsic runner robustness that is always on, so a bad +// chunk is flagged-and-skipped instead of poisoning the pipeline (empty draft → +// empty edit → empty export) or wedging the whole book on a single call. + +// Disposition is the resolved state of a chunk×stage over its checkpoints. +type Disposition string + +const ( + // DispOK — a usable completion; its text feeds the next stage and is the + // authoritative checkpoint. + DispOK Disposition = "ok" + // DispFlagged — the completion is unusable (refusal / echo / truncation loop + // / empty / decode error) and retries (if any) are exhausted; the chunk is + // flagged for a human and later stages are skipped. Money is still accounted. + DispFlagged Disposition = "flagged" + // DispSkipped — this stage was NOT attempted because an earlier stage of the + // same chunk was flagged (no garbage draft → no paid edit over garbage). + DispSkipped Disposition = "skipped" +) + +// FlagReason is the tagged cause of a flag — a contract enum like the Finish* +// constants (D2.2: tagging drives retry policy). The runner re-attacks ONLY the +// retryable subset; the rest are deterministic per model/channel, so a same-model +// retry would just re-refuse and re-bill (routing to channel B / escalation is +// Фаза 2). Some constants are DEFINED here for contract completeness but are not +// yet emitted by Веха 2 — see the notes — they belong to later steps. +type FlagReason string + +const ( + reasonOK FlagReason = "" // sentinel: not a flag + + // Emitted by classify() — the retryable pair (D2: bigger max_tokens on the + // attempt axis, up to the regenerate cap, then flag). + FlagLength FlagReason = "length" // truncated at max_tokens (a genuine cut, not a loop) + FlagEmpty FlagReason = "empty" // 2xx with no usable text (thinking likely ate the whole budget) + + // Emitted by classify() — deterministic, NOT retryable on the same model. + FlagLoopDegenerate FlagReason = "loop_degenerate" // repetition loop at a length cut (bigger budget just buys more loop, D2.3) + FlagHardRefusal FlagReason = "hard_refusal" // provider finish_reason=refusal + FlagSoftRefusal FlagReason = "soft_refusal" // refusal-blacklist match on a short output (the real insurance, D2.4) + FlagContentFilter FlagReason = "content_filter" // provider finish_reason=content_filter (best-effort — emission unverified across DS/GLM/Kimi/grok, D2.4) + FlagCJKArtifact FlagReason = "cjk_artifact" // output echoed the CJK source instead of translating (D3.4; фолбэк-черновик — шаг 7, пока просто флаг) + + // Emitted by the runner's billed-decode path. + FlagDecodeError FlagReason = "decode_error" // 2xx with an unreadable body — billed, conservatively settled, flagged + + // Reserved for later steps — DEFINED for contract stability, NOT emitted by + // Веха 2. coverage_fail / excision_suspect are verdicts of the configurable + // coverage gate (шаг 6); hard_block / upstream_not_ok are for HTTP-level + // content blocks handled when escalation lands (шаг 7). + FlagCoverageFail FlagReason = "coverage_fail" + FlagExcisionSuspect FlagReason = "excision_suspect" + FlagHardBlock FlagReason = "hard_block" + FlagUpstreamNotOK FlagReason = "upstream_not_ok" +) + +// decodeErrorFinish is the finish_reason the runner stores on a billed-but- +// unreadable 2xx (BilledDecodeError). classify() recognises it so a RESUMED +// decode checkpoint re-resolves to the same FlagDecodeError verdict the live +// path assigned — live and resume must agree (determinism of the resolve). +const decodeErrorFinish = "decode_error" + +// retryable reports whether a flag may be re-attacked on the SAME model along the +// attempt axis. Only length/empty: both are budget symptoms a bigger max_tokens +// can cure. Everything else is deterministic (refusal/filter/echo/loop/decode) — +// re-attacking burns money on a guaranteed repeat (D2.2/D2.3). +func (r FlagReason) retryable() bool { + return r == FlagLength || r == FlagEmpty +} + +// disposition maps a reason to the resolved chunk×stage state. +func (r FlagReason) disposition() Disposition { + if r == reasonOK { + return DispOK + } + return DispFlagged +} + +// classifyInput is everything classify needs. It is more than the "(text, +// finish)" shorthand because a faithful 1:1 port of eval/refusal_bench.py +// (03-implementation-notes §3.7) needs the source length (soft-refusal is a +// refusal pattern on an ANOMALOUSLY SHORT output — a long translation that +// merely quotes "I'm sorry, but…" as dialogue must NOT flag) and the target +// language (CJK-echo detection is meaningless when translating INTO a CJK +// language). +type classifyInput struct { + Source string + Output string + Finish string + TargetLang string +} + +// classification is classify's verdict. +type classification struct { + Reason FlagReason + Detail string +} + +func (c classification) ok() bool { return c.Reason == reasonOK } + +// classify is the single verdict function. ORDER MATTERS (D2.1/D2.4): the +// deterministic hard signals (decode / content_filter / refusal finish) and the +// text refusal-blacklist / CJK-echo are checked BEFORE length/empty, so a +// truncated refusal is flagged as a refusal (deterministic, no paid re-attack) +// rather than as a length cut (retryable). Pure: no time, no randomness, no map +// iteration — resume reproduces the identical verdict from the identical +// checkpoint. +func classify(in classifyInput) classification { + // Strip a block the model may have leaked into content + // (mirrors refusal_bench) before judging emptiness/length. + out := strings.TrimSpace(stripThink(in.Output)) + finish := in.Finish + + // 1) Deterministic finish-reason signals — never retried. + switch finish { + case decodeErrorFinish: + return classification{FlagDecodeError, "billed 2xx with an unreadable body"} + case llm.FinishContentFilter: + return classification{FlagContentFilter, "provider finish_reason=content_filter"} + case llm.FinishRefusal: + return classification{FlagHardRefusal, "provider finish_reason=refusal"} + } + + // 2) Text refusal-blacklist / CJK-echo — ДО length/empty (only meaningful on + // non-empty text; a truncated refusal must not become a paid length retry). + if out != "" { + if isRefusal(out, in.Source) { + return classification{FlagSoftRefusal, "refusal-blacklist match on a short output"} + } + if !isCJKTarget(in.TargetLang) { + if share := cjkShare(out); share > cjkEchoThreshold { + return classification{FlagCJKArtifact, fmt.Sprintf("CJK share %.0f%% in output (untranslated echo)", share*100)} + } + } + } + + // 3) Empty completion — retryable (thinking usually ate the entire budget). + if out == "" { + return classification{FlagEmpty, "empty completion (finish=" + finish + ")"} + } + + // 4) Length cut — retryable, UNLESS the output is a degenerate repetition + // loop, in which case a bigger budget only buys more loop (D2.3): flag now. + if finish == llm.FinishLength { + if degenerateLoop(out) { + return classification{FlagLoopDegenerate, "repetition loop at a length cut"} + } + return classification{FlagLength, "truncated at max_tokens"} + } + + // 5) A usable completion. + return classification{reasonOK, ""} +} + +// --- refusal blacklist (en/ru/zh/ja) — ported 1:1 from eval/refusal_bench.py --- + +var refusalPatterns = []string{ + `I (?:can(?:'|no)t|cannot|won'?t|am (?:not able|unable)) (?:to )?(?:assist|help|translate|continue|provide|fulfill)`, + `I'?m sorry,? but`, + `against (?:my|our) (?:content )?polic`, + `content polic(?:y|ies)`, + `не могу (?:помочь|перевести|продолжить|выполнить)`, + `я не буду переводить`, + `противоречит (?:политике|правилам)`, + `としてお答えできません`, + `申し訳ありませんが`, + `我(?:不能|无法)(?:协助|翻译|继续)`, + `抱歉[,,]`, + `^\s*\[?(?:REFUSED|ОТКАЗ)\]?\s*$`, +} + +var refusalRE = regexp.MustCompile("(?im)" + strings.Join(refusalPatterns, "|")) + +var thinkRE = regexp.MustCompile(`(?s).*?\s*`) + +func stripThink(s string) string { return thinkRE.ReplaceAllString(s, "") } + +// isRefusal ports refusal_bench: a refusal-blacklist match AND an anomalously +// short output (shorter than max(400 runes, half the source), so a long faithful +// translation that merely contains a refusal-like phrase does not flag). +func isRefusal(out, src string) bool { + if !refusalRE.MatchString(out) { + return false + } + threshold := utf8.RuneCountInString(src) / 2 + if threshold < 400 { + threshold = 400 + } + return utf8.RuneCountInString(out) < threshold +} + +// --- CJK-echo (untranslated source returned instead of a translation, D3.4) --- + +// cjkEchoThreshold: >15% Han+kana in a non-CJK-target output means the model +// echoed the source (refusal_bench cjk_share > 0.15). +const cjkEchoThreshold = 0.15 + +func cjkShare(s string) float64 { + if s == "" { + return 0 + } + cjk, total := 0, 0 + for _, r := range s { + total++ + if unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana) { + cjk++ + } + } + if total == 0 { + return 0 + } + return float64(cjk) / float64(total) +} + +// isCJKTarget reports whether the translation TARGET is a CJK language, in which +// case CJK in the output is expected and the echo check is skipped. Phase-1 +// scope is →ru, so this is normally false. +func isCJKTarget(lang string) bool { + switch strings.ToLower(strings.TrimSpace(lang)) { + case "zh", "ja", "ko", "zh-cn", "zh-tw": + return true + } + return false +} + +// --- degeneration detector (n-gram loop) — runs BEFORE the length retry (D2.3) --- + +// degenerateLoop reports whether the text is a repetition loop: the word-level +// trigram distinctness collapses when a phrase/sentence repeats to fill the +// budget. Pure and deterministic (only len(distinct) is used, never map order). +// Below a floor of words a length cut of genuinely short text is not judged a +// loop. CJK output (no spaces → few "words") is left to the echo check. +func degenerateLoop(text string) bool { + const n = 3 + const minWords = 30 + const distinctRatio = 0.25 // <25% distinct trigrams ⇒ ≥75% are repeats ⇒ loop + words := strings.Fields(text) + if len(words) < minWords { + return false + } + total := len(words) - n + 1 + seen := make(map[string]struct{}, total) + for i := 0; i+n <= len(words); i++ { + seen[strings.Join(words[i:i+n], "\x00")] = struct{}{} + } + return float64(len(seen))/float64(total) < distinctRatio +} + +// --- max_tokens on the attempt axis (D2.3) --- + +// maxTokensForAttempt is the PURE output-token budget for a retry attempt: +// attempt 0 = base, each regeneration DOUBLES it (D2.3 remedy for a length cut — +// the previous budget was too small). Purity is load-bearing: the value enters +// request_hash, so resume must reproduce the identical per-attempt budget. Its +// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is +// a loud --resnapshot, not a silent checkpoint miss on retried chunks (the same +// discipline as estimatorVersion). +func maxTokensForAttempt(base, attempt int) int { + if attempt <= 0 { + return base + } + if attempt > 20 { // defensive: never shift by a runaway amount (overflow guard) + attempt = 20 + } + return base << uint(attempt) +} diff --git a/backend/internal/pipeline/disposition_test.go b/backend/internal/pipeline/disposition_test.go new file mode 100644 index 0000000..9868129 --- /dev/null +++ b/backend/internal/pipeline/disposition_test.go @@ -0,0 +1,138 @@ +package pipeline + +import ( + "strings" + "testing" + + "textmachine/backend/internal/llm" +) + +// classify: ORDER is the contract (D2.1/D2.4) — refusal/echo/deterministic finish +// signals are decided BEFORE length/empty, so a truncated refusal is not turned +// into a paid length retry. +func TestClassifyOrder(t *testing.T) { + const ru = "ru" + cases := []struct { + name string + in classifyInput + wantReason FlagReason + }{ + {"clean stop", classifyInput{Source: "静かな朝。", Output: "Тихое утро.", Finish: llm.FinishStop, TargetLang: ru}, reasonOK}, + {"empty at stop", classifyInput{Source: "静かな朝。", Output: " ", Finish: llm.FinishStop, TargetLang: ru}, FlagEmpty}, + {"empty at length (thinking ate budget)", classifyInput{Source: "静かな朝。", Output: "", Finish: llm.FinishLength, TargetLang: ru}, FlagEmpty}, + {"genuine length cut", classifyInput{Source: strings.Repeat("текст ", 50), Output: "Начало предложения обрывается на", Finish: llm.FinishLength, TargetLang: ru}, FlagLength}, + {"length but repetition loop", classifyInput{Source: "x", Output: strings.Repeat("ло ", 60), Finish: llm.FinishLength, TargetLang: ru}, FlagLoopDegenerate}, + {"truncated soft refusal (NOT length)", classifyInput{Source: "静かな朝、彼女は本を開いた。長い一日の始まりだった。", Output: "Я не могу помочь с этим запросом.", Finish: llm.FinishLength, TargetLang: ru}, FlagSoftRefusal}, + {"english refusal short", classifyInput{Source: "長い長い長い長い文章です。", Output: "I'm sorry, but I cannot assist with that.", Finish: llm.FinishStop, TargetLang: ru}, FlagSoftRefusal}, + {"cjk echo (untranslated)", classifyInput{Source: "彼は言った。", Output: "彼は言った。とても静かな朝だった。", Finish: llm.FinishStop, TargetLang: ru}, FlagCJKArtifact}, + {"provider content_filter finish", classifyInput{Source: "x", Output: "partial", Finish: llm.FinishContentFilter, TargetLang: ru}, FlagContentFilter}, + {"provider refusal finish", classifyInput{Source: "x", Output: "", Finish: llm.FinishRefusal, TargetLang: ru}, FlagHardRefusal}, + {"resumed decode checkpoint", classifyInput{Source: "x", Output: "", Finish: decodeErrorFinish, TargetLang: ru}, FlagDecodeError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classify(tc.in) + if got.Reason != tc.wantReason { + t.Fatalf("classify reason = %q, want %q (detail %q)", got.Reason, tc.wantReason, got.Detail) + } + if (got.Reason == reasonOK) != got.ok() { + t.Fatalf("ok() inconsistent for reason %q", got.Reason) + } + }) + } +} + +// A refusal that is NOT anomalously short (a long faithful translation that +// merely quotes a refusal-like phrase as dialogue) must NOT flag. +func TestClassifyLongOutputWithRefusalPhraseIsOK(t *testing.T) { + src := strings.Repeat("短", 100) // 100 CJK runes → threshold max(400, 50) = 400 + out := "«Я не могу помочь тебе», — тихо сказал он, отводя взгляд. " + strings.Repeat("Слова растворялись в утреннем тумане, и никто их не услышал. ", 12) + got := classify(classifyInput{Source: src, Output: out, Finish: llm.FinishStop, TargetLang: "ru"}) + if !got.ok() { + t.Fatalf("a long translation quoting a refusal phrase must be ok, got %q", got.Reason) + } +} + +// Determinism: identical input yields the identical verdict (pure function). +func TestClassifyDeterministic(t *testing.T) { + in := classifyInput{Source: "彼は静かに歩いた。", Output: strings.Repeat("эхо ", 40), Finish: llm.FinishLength, TargetLang: "ru"} + a, b := classify(in), classify(in) + if a != b { + t.Fatalf("classify is not deterministic: %+v vs %+v", a, b) + } +} + +func TestRetryableSubset(t *testing.T) { + retry := map[FlagReason]bool{FlagLength: true, FlagEmpty: true} + all := []FlagReason{ + reasonOK, FlagLength, FlagEmpty, FlagLoopDegenerate, FlagHardRefusal, FlagSoftRefusal, + FlagContentFilter, FlagCJKArtifact, FlagDecodeError, FlagCoverageFail, FlagExcisionSuspect, + FlagHardBlock, FlagUpstreamNotOK, + } + for _, r := range all { + if got, want := r.retryable(), retry[r]; got != want { + t.Fatalf("%q.retryable() = %v, want %v (only length/empty are retryable)", r, got, want) + } + } + if reasonOK.disposition() != DispOK { + t.Fatal("reasonOK must resolve to DispOK") + } + if FlagLength.disposition() != DispFlagged { + t.Fatal("a flag reason must resolve to DispFlagged") + } +} + +// maxTokensForAttempt is pure and doubles per attempt (D2.3), which is what puts +// a DIFFERENT max_tokens (and thus request_hash) on each regeneration. +func TestMaxTokensForAttempt(t *testing.T) { + base := 2048 + for attempt, want := range map[int]int{0: base, 1: 2 * base, 2: 4 * base, 3: 8 * base} { + if got := maxTokensForAttempt(base, attempt); got != want { + t.Fatalf("maxTokensForAttempt(%d, %d) = %d, want %d", base, attempt, got, want) + } + } + // Overflow guard: an absurd attempt is clamped, never a negative shift. + if got := maxTokensForAttempt(base, 999); got <= 0 { + t.Fatalf("maxTokensForAttempt overflow guard failed: %d", got) + } +} + +func TestDegenerateLoop(t *testing.T) { + if !degenerateLoop(strings.Repeat("одно и то же ", 40)) { + t.Fatal("a repeated phrase must be a degenerate loop") + } + healthy := "Она открыла книгу и начала читать первую главу, где юный герой впервые покидал родную деревню навстречу далёким горам и неизвестной судьбе, полной опасностей." + if degenerateLoop(healthy) { + t.Fatal("healthy prose must NOT be a degenerate loop") + } + if degenerateLoop("короткий обрыв на пол") { + t.Fatal("a short length cut is not enough evidence for a loop") + } +} + +func TestIsRefusalShortVsLong(t *testing.T) { + if !isRefusal("Извините, я не могу перевести это.", "abc") { + t.Fatal("a short refusal must be detected") + } + long := strings.Repeat("Это обычный длинный перевод без всякого отказа. ", 30) + if isRefusal(long, "abc") { + t.Fatal("a long non-refusal must not be flagged") + } +} + +func TestCJKShareAndTarget(t *testing.T) { + if s := cjkShare("彼は言った"); s < 0.9 { + t.Fatalf("all-CJK text share = %.2f, want ~1", s) + } + if s := cjkShare("Полностью русский текст"); s > 0.01 { + t.Fatalf("russian text share = %.2f, want ~0", s) + } + if !isCJKTarget("ja") || !isCJKTarget("ZH") || isCJKTarget("ru") { + t.Fatal("isCJKTarget classification wrong") + } + // Into a CJK target, CJK output is expected — no echo flag. + got := classify(classifyInput{Source: "Утро.", Output: "静かな朝。", Finish: llm.FinishStop, TargetLang: "ja"}) + if got.Reason == FlagCJKArtifact { + t.Fatal("CJK output must not be an echo artifact when the target IS CJK") + } +} diff --git a/backend/internal/pipeline/render.go b/backend/internal/pipeline/render.go index 8d77fc2..890a5b9 100644 --- a/backend/internal/pipeline/render.go +++ b/backend/internal/pipeline/render.go @@ -31,8 +31,11 @@ import ( // chunkerVersion versions the segmentation/ingestion rules (включая // нормализацию источника NormalizeSource); it is part of the snapshot, so // re-chunking a book is an explicit re-translation, not a silent cache miss -// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в). -const chunkerVersion = "chunker-v1-wholefile-nfc" +// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в). Веха 2 replaced the whole-file "one chunk" of +// Фаза 0 with the chapter/chunk splitter (chunker.go), so the version bumps: +// any Фаза-0 project DB re-pins loudly via the --resnapshot gate (a re-chunk is +// a deliberate re-translation), never a silent divergent re-pay. +const chunkerVersion = "chunker-v2-ff-para-pack" // estimatorVersion versions EstimateTokens: его выход входит в max_tokens и // через него в request-hash, поэтому перекалибровка весов — тоже явная @@ -40,6 +43,15 @@ const chunkerVersion = "chunker-v1-wholefile-nfc" // ревью: code-only правка эстиматора пере-оплатила бы полкниги). const estimatorVersion = "estimator-v0" +// maxTokensPolicyVersion versions the attempt→max_tokens scaling +// (maxTokensForAttempt, disposition.go). attempt-0 budget is already covered by +// estimatorVersion + defaults, but a change to the RETRY scaling would silently +// shift the request_hash of every attempt≥1 (a missed checkpoint → re-pay on +// retried chunks). Folding this version into the snapshot makes such a change a +// loud --resnapshot instead — the same discipline as estimatorVersion, applied +// to the regeneration axis (Веха 2). +const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt" + // userSeparator splits a prompt template file into the system part and the // user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже // правит файлы, не код). @@ -181,6 +193,33 @@ func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, mod return hex.EncodeToString(h.Sum(nil)) } +// msgsContentHash is a content signature of the rendered messages, INDEPENDENT +// of attempt/max_tokens. It guards the chunk_status resume fast-path (runner.go): +// the source bytes are NOT folded into the snapshot (only the chunker RULES and +// the semantic brief are), so a positional chunk_status row must be re-validated +// against the current rendered content — otherwise an edited source would serve +// a stale, divergent translation with no cache miss (the determinism invariant's +// named failure; self-review Веха 2). Length-prefixed like RequestHash so +// content with NUL bytes cannot forge field/message boundaries. The template is +// already snapshot-pinned, so this only has to catch source/draft edits. +func msgsContentHash(msgs []llm.Message) string { + h := sha256.New() + var lb [8]byte + w := func(s string) { + binary.LittleEndian.PutUint64(lb[:], uint64(len(s))) + h.Write(lb[:]) + h.Write([]byte(s)) + } + w("tm-content-v1") + binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs))) + h.Write(lb[:]) + for _, m := range msgs { + w(m.Role) + w(m.Content) + } + return hex.EncodeToString(h.Sum(nil)) +} + // NormalizeSource canonicalizes the source chunk so the request-hash is stable // across editors and operating systems (находка ревью): strip a UTF-8 BOM, // CRLF/CR → LF, Unicode NFC, trim surrounding whitespace. Без этого файл, diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index e2a7414..ef403f9 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -19,11 +19,15 @@ import ( "textmachine/backend/internal/store" ) -// runner.go: the Phase-0 mini-runner — one chunk through the configured stage -// list (C1: draft→edit) with full money discipline: reserve → call → settle+ -// checkpoint (одна транзакция) → request_log. Resume: найденный чекпоинт = -// вызов не повторяется и не оплачивается. Гейты/эскалация/циклы по главам — -// Фаза 1, сюда же (код раннера). +// runner.go: the Веха-2 book runner — every (chapter, chunk) from the chunker +// through the configured stage list (C1: draft→edit) with full money discipline: +// reserve → call → settle+checkpoint (одна транзакция) → request_log. A bad +// chunk is FLAGGED (disposition, chunk_status) and the loop continues (D2), not +// a run-crash; only an infra failure aborts. Resume reads chunk_status BEFORE +// rendering, so a done or terminally-flagged chunk is never re-attacked or +// re-billed. Retries walk the `attempt` axis (bigger max_tokens for the +// retryable length/empty subset, up to the regenerate cap, then flag). +// Escalation and the real linguistic chunker are later steps of Фаза 1. // Runner executes a book's pipeline. type Runner struct { @@ -175,10 +179,15 @@ func (r *Runner) snapshotID() (id, payload string, err error) { Capability json.RawMessage `json:"capability,omitempty"` } snap := struct { - BriefHash string `json:"brief_hash"` - ChunkerVersion string `json:"chunker_version"` - EstimatorVersion string `json:"estimator_version"` - PipelineCore string `json:"pipeline_core"` + BriefHash string `json:"brief_hash"` + ChunkerVersion string `json:"chunker_version"` + EstimatorVersion string `json:"estimator_version"` + // MaxTokensPolicy versions the attempt→max_tokens scaling (Веха 2): a + // change to the retry doubling shifts attempt≥1 request_hashes, so it + // belongs in the snapshot as a loud invalidation (same class as + // estimator_version, applied to the regeneration axis). + MaxTokensPolicy string `json:"max_tokens_policy"` + PipelineCore string `json:"pipeline_core"` // Defaults влияют на maxTokens, а тот входит в request-hash: без них // правка max_output_ratio молча инвалидировала бы все чекпоинты в // обход snapshot-гейта (находка ревью). @@ -201,6 +210,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) { BriefHash: r.Book.BriefHash(), ChunkerVersion: chunkerVersion, EstimatorVersion: estimatorVersion, + MaxTokensPolicy: maxTokensPolicyVersion, PipelineCore: r.Pipeline.Core, MaxOutputRatio: r.Pipeline.Defaults.MaxOutputRatio, MinMaxTokens: r.Pipeline.Defaults.MinMaxTokens, @@ -247,33 +257,72 @@ func (r *Runner) snapshotID() (id, payload string, err error) { return hex.EncodeToString(sum[:]), string(data), nil } -// StageResult reports one executed stage. +// StageResult reports one executed stage of a chunk. type StageResult struct { Stage string Role string Model string // фактически ответившая модель - FromResume bool // served from a checkpoint, no call made + FromResume bool // no provider call was made THIS run (fully served from checkpoints) Usage llm.Usage - CostUSD float64 + CostUSD float64 // THIS run's spend (0 when fully served from checkpoints) + CumCostUSD float64 // sum across ALL attempts of this chunk×stage (F3-honest, incl. retries) LatencyMS int FinishReason string - Text string + Text string // the usable output (only on an ok disposition) + Disposition Disposition + FlagReason FlagReason // "" when ok + Detail string + Attempts int } -// RunResult is the whole chunk's outcome. -type RunResult struct { - BookID string - Chapter int - ChunkIdx int - Stages []StageResult - FinalText string - TotalUSD float64 +// ChunkOutcome is one chunk's result across the stage list. +type ChunkOutcome struct { + Chapter int + ChunkIdx int + Stages []StageResult + FinalText string // the last stage's output; "" when the chunk is flagged + Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state) + FlagReason FlagReason // the flagging stage's reason ("" when ok) + CostUSD float64 // THIS run's spend on this chunk } -// TranslateOneChunk runs the configured stages over the book's source file as -// a single chunk (Фаза 0). Chapter/chunk are fixed at 1/0 until the chunker -// lands in Phase 1. -func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) { +// BookResult aggregates a whole run over every chapter×chunk. +type BookResult struct { + BookID string + Chunks []ChunkOutcome + TotalUSD float64 // THIS run's spend across all chunks + Flagged int // number of flagged chunks (acceptance допускает N) +} + +// ExitCode is the run's shell disposition: 0 clean, 2 completed-with-flags +// (acceptance допускает N флагов — приёмка это разрешает). An infra failure is +// an error from TranslateBook, which the CLI maps to 1. +func (b *BookResult) ExitCode() int { + if b.Flagged > 0 { + return 2 + } + return 0 +} + +// CompletedWithFlags is the typed sentinel the CLI maps to exit code 2: the book +// finished end-to-end but N chunks were flagged for a human. It is NOT an infra +// failure (a plain error → exit 1); it is a "clean run, attention needed" signal +// carried up through `func run() error` in the idiomatic Go way. +type CompletedWithFlags struct { + Flagged int + Total int +} + +func (e *CompletedWithFlags) Error() string { + return fmt.Sprintf("completed with %d/%d chunk(s) flagged for review", e.Flagged, e.Total) +} + +// TranslateBook runs the whole book: split the normalized source into +// chapter/chunk units (chunker.go) and drive each chunk through the stage list. +// A bad chunk is flagged and the loop CONTINUES (D2); only an infra failure +// (ceiling, config change without --resnapshot, unbilled call failure, store +// error) aborts with an error, from which resume continues. +func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) { srcRaw, err := os.ReadFile(r.Book.SourceFile) if err != nil { return nil, fmt.Errorf("pipeline: read source: %w", err) @@ -284,6 +333,8 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) { return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile) } + // Snapshot is book-level (brief + stage plan + memory), computed once and + // upserted before the loop; every job pins to it. snapID, snapPayload, err := r.snapshotID() if err != nil { return nil, err @@ -292,96 +343,276 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) { return nil, err } - const chapter, chunkIdx = 1, 0 - res := &RunResult{BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx} - prev := "" + chunks := SplitChunks(source) + if len(chunks) == 0 { + return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile) + } - for _, st := range r.Pipeline.Stages { - sr, err := r.runStage(ctx, st, snapID, chapter, chunkIdx, source, prev) + res := &BookResult{BookID: r.Book.BookID} + for _, ch := range chunks { + outcome, err := r.translateChunk(ctx, snapID, ch) if err != nil { + // Infra failure: abort. Chunks already committed are checkpointed; + // resume continues from here at $0 for the done work. return res, err } - res.Stages = append(res.Stages, *sr) - res.TotalUSD += sr.CostUSD - prev = sr.Text + res.Chunks = append(res.Chunks, *outcome) + res.TotalUSD += outcome.CostUSD + if outcome.Disposition == DispFlagged { + res.Flagged++ + } } - res.FinalText = prev return res, nil } -func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, chapter, chunkIdx int, source, prev string) (*StageResult, error) { - job, err := r.Store.EnsureJob(r.Book.BookID, chapter, st.Name, snapID) +// translateChunk drives ONE chunk through the stage list. Stages run in order, +// each feeding the next; the FIRST flagged stage stops the chunk — later stages +// are recorded `skipped` (no paid edit over a garbage draft, D2). Returns an +// error only on an infra failure. +func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*ChunkOutcome, error) { + out := &ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: DispOK} + prev := "" + flagged := false + var flagReason FlagReason + + for stageIdx, st := range r.Pipeline.Stages { + if flagged { + // An earlier stage flagged → this stage is not attempted or billed. + detail := fmt.Sprintf("skipped: an upstream stage was flagged (%s)", flagReason) + if err := r.Store.UpsertChunkStatus(store.ChunkStatus{ + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name, + SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason), + Detail: detail, + }); err != nil { + return out, fmt.Errorf("pipeline: record skipped chunk_status: %w", err) + } + out.Stages = append(out.Stages, StageResult{ + Stage: st.Name, Role: st.Role, Model: st.Model, + Disposition: DispSkipped, FlagReason: flagReason, Detail: detail, + }) + continue + } + + sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev) + if err != nil { + return out, err + } + out.Stages = append(out.Stages, *sr) + out.CostUSD += sr.CostUSD + if sr.Disposition == DispFlagged { + flagged = true + flagReason = sr.FlagReason + continue + } + prev = sr.Text + } + + if flagged { + out.Disposition = DispFlagged + out.FlagReason = flagReason + out.FinalText = "" // garbage/refusal never propagates to the next chunk or export + } else { + out.FinalText = prev + } + return out, nil +} + +// runStage runs ONE stage of ONE chunk to a terminal disposition. It first +// resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge +// #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume +// or a fresh reserve/call/settle → classify → retry the retryable {length,empty} +// subset with a doubled budget up to the regenerate cap, then flag. Returns an +// error ONLY on an infra failure; a bad completion is a disposition, never an +// error. +func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch Chunk, prev string) (*StageResult, error) { + job, err := r.Store.EnsureJob(r.Book.BookID, ch.Chapter, st.Name, snapID) if err != nil { return nil, err } - // Snapshot pinning (Р6): джоба заморожена на снапшоте своего старта. Если - // конфиги/промпты с тех пор изменились — это инвалидация чекпоинтов и - // пере-оплата; выполняем её только по явной команде. + // Snapshot pinning (Р6): a job frozen on a stale snapshot is a loud stop + // unless --resnapshot explicitly accepts the re-translation. if job.SnapshotID != snapID { if !r.Resnapshot { return nil, fmt.Errorf("pipeline: job %s/ch%d/%s was started under snapshot %.12s, current config renders snapshot %.12s — конфиг/промпты изменились; уже оплаченные чекпоинты станут недействительны и вызовы будут пере-оплачены; повторить с --resnapshot, чтобы принять это явно", - r.Book.BookID, chapter, st.Name, job.SnapshotID, snapID) + r.Book.BookID, ch.Chapter, st.Name, job.SnapshotID, snapID) } if err := r.Store.UpdateJobSnapshot(job.ID, snapID); err != nil { return nil, err } r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12]) } - // Статус 'running' выставляется НЕ здесь, а прямо перед вызовом провайдера: - // иначе отказ потолка/ошибка сборки клиента/промах гейта оставляли бы - // джобу навсегда в 'running' (находка ревью). Отказ потолка — retriable, - // джоба остаётся 'pending'; настоящая ошибка — 'failed'. - msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev}) + // Render the wire messages up front. This is cheap (string substitution — the + // expensive part is the LLM call, still gated below) and it lets the resume + // fast-path be CONTENT-VERIFIED: the source bytes are not in the snapshot, so + // a positional chunk_status row must be checked against the current rendered + // content, else an edited source would serve a stale, divergent translation. + msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: ch.Text, Draft: prev}) if err != nil { return nil, err } + contentHash := msgsContentHash(msgs) - // max_tokens: калибровка полигона — русский выход ≈1.9× входа в токенах; - // ratio из конфига, floor защищает короткие чанки. Входит в request-hash - // (меняет вызов), поэтому считается детерминированно от исходника. - inputEst := EstimateTokens(source) - maxTokens := int(float64(inputEst) * r.Pipeline.Defaults.MaxOutputRatio) - if maxTokens < r.Pipeline.Defaults.MinMaxTokens { - maxTokens = r.Pipeline.Defaults.MinMaxTokens + // Anti-wedge #1 + content-guard (self-review): resume resolves from + // chunk_status BEFORE any LLM call. A flagged chunk is not in TM (garbage + // never commits), so absence-in-TM ≠ "not done" — the disposition row is the + // resume authority, and a terminally-flagged chunk is NOT re-attacked (no + // infinite paid loop, even if the regenerate budget was raised). The row is + // trusted ONLY when snapshot AND content both match: a stale-snapshot row + // (post --resnapshot) or a stale-content row (source edited, not in the + // snapshot) is ignored → the attempt loop below re-derives it content-safely + // (a source edit becomes a silent per-chunk re-translate, §3.4, not a stale + // serve). A `skipped` row is ignored too: translateChunk re-derives the skip. + if cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name); err != nil { + return nil, err + } else if cs != nil && cs.SnapshotID == snapID && cs.ContentHash == contentHash && cs.Disposition != string(DispSkipped) { + return r.resumeFromChunkStatus(ctx, st, ch, cs) } - // attempt 0: измерение регенераций подключит гейт-цикл Фазы 1. - const attempt = 0 - reqHash := RequestHash(r.Book.BookID, chapter, chunkIdx, attempt, st.Name, st.Role, st.Model, - st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs) + // max_tokens base is sized from the text THIS stage processes: the source for + // the translator, the prior draft for later stages (D2.5 — the monolingual + // editor works over the Russian draft ≈1.9× the CJK source, so sizing edit + // from source under-budgets and false-triggers a length retry). + sizingText := ch.Text + if stageIdx > 0 { + sizingText = prev + } + baseMaxTokens := int(float64(EstimateTokens(sizingText)) * r.Pipeline.Defaults.MaxOutputRatio) + if baseMaxTokens < r.Pipeline.Defaults.MinMaxTokens { + baseMaxTokens = r.Pipeline.Defaults.MinMaxTokens + } - // Дополняем ReqInfo, СОХРАНЯЯ решения допуска (LogBodies) — перезапись с - // нуля отрезала бы задокументированный debug-канал (находка ревью). + // Дополняем ReqInfo, СОХРАНЯЯ решения допуска (LogBodies) — перезапись с нуля + // отрезала бы задокументированный debug-канал (находка ревью). ri, _ := obs.ReqInfoFromContext(ctx) - ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, chapter, chunkIdx, st.Name, st.Role + ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, st.Role ctx = obs.WithReqInfo(ctx, ri) - // Resume path: a checkpoint means the call already happened and was paid — - // serve it, log the hit, never re-bill (приёмка: kill -9 теряет ≤1 вызов). - // Пустой чекпоинт (оплаченный отказ/обрезка/нечитаемое тело) НЕ подаётся - // дальше молча: черновик="" отравил бы редактуру и экспорт (находка ревью). - if cp, err := r.Store.GetCheckpoint(reqHash); err != nil { - return nil, err - } else if cp != nil { - if strings.TrimSpace(cp.ResponseText) == "" { - return nil, fmt.Errorf("pipeline: stage %s has a BILLED but empty checkpoint (finish=%s, hash %.12s) — вызов был оплачен, но пригодного текста нет; до регенераций Фазы 1: удалить строку checkpoints вручную или дождаться механизма attempt", st.Name, cp.FinishReason, reqHash) - } - var usage llm.Usage - _ = json.Unmarshal([]byte(cp.UsageJSON), &usage) - r.Store.LogRequest(ctx, r.Log, store.RequestLog{ - BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, - Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual, - RequestHash: reqHash, TMHit: true, OK: true, FinishReason: cp.FinishReason, - }) - if err := r.Store.SetJobStatus(job.ID, "done"); err != nil { - return nil, err - } - r.Log.InfoContext(ctx, "stage served from checkpoint", "stage", st.Name, "hash", reqHash[:12]) - return &StageResult{Stage: st.Name, Role: st.Role, Model: cp.ModelActual, - FromResume: true, Usage: usage, CostUSD: 0, FinishReason: cp.FinishReason, Text: cp.ResponseText}, nil + maxRegen := r.Pipeline.Retries.RegenerateBeforeEscalate + if maxRegen < 0 { + maxRegen = 0 } + var cumCost, runCost float64 + var last stageAttempt + anyFresh := false + attemptsMade := 0 + for attempt := 0; ; attempt++ { + maxTokens := maxTokensForAttempt(baseMaxTokens, attempt) + att, err := r.runAttempt(ctx, st, snapID, ch, job, attempt, maxTokens, msgs) + if err != nil { + return nil, err // infra failure + } + attemptsMade = attempt + 1 + cumCost += att.cumCost + runCost += att.runCost + anyFresh = anyFresh || att.freshCall + last = att + if att.cls.ok() { + break + } + // Flagged: re-attack only the retryable subset, only while regenerations + // remain (a bigger budget on the attempt axis, D2.3). Everything else is + // deterministic — a same-model retry would re-refuse and re-bill (D2.2). + if att.cls.Reason.retryable() && attempt < maxRegen { + r.Log.WarnContext(ctx, "stage flagged, regenerating with a larger budget", + "stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, + "attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, attempt+1)) + continue + } + break + } + + disposition := last.cls.Reason.disposition() + finalHash := "" + if disposition == DispOK { + finalHash = last.reqHash + } + // chunk_status is a RESOLVE over the checkpoints: cost_usd sums every attempt + // (F3-honest — retries are counted), final_hash points at the authoritative + // checkpoint the ok path serves on resume. + if err := r.Store.UpsertChunkStatus(store.ChunkStatus{ + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name, + SnapshotID: snapID, ContentHash: contentHash, Disposition: string(disposition), FlagReason: string(last.cls.Reason), + Attempts: attemptsMade, FinalHash: finalHash, CostUSD: cumCost, Detail: last.cls.Detail, + }); err != nil { + return nil, fmt.Errorf("pipeline: record chunk_status: %w", err) + } + + sr := &StageResult{ + Stage: st.Name, Role: st.Role, Model: last.modelActual, + FromResume: !anyFresh, + Usage: last.usage, + CostUSD: runCost, + CumCostUSD: cumCost, + LatencyMS: last.latency, + FinishReason: last.finish, + Disposition: disposition, + FlagReason: last.cls.Reason, + Detail: last.cls.Detail, + Attempts: attemptsMade, + } + if disposition == DispOK { + sr.Text = last.text + } else { + r.Log.WarnContext(ctx, "stage flagged", "stage", st.Name, "chapter", ch.Chapter, + "chunk", ch.ChunkIdx, "reason", string(last.cls.Reason), "attempts", attemptsMade, + "cum_cost_usd", fmt.Sprintf("%.6f", cumCost)) + } + return sr, nil +} + +// stageAttempt is the result of one attempt (a checkpoint hit or a fresh call). +type stageAttempt struct { + reqHash string + cls classification + text string + usage llm.Usage + finish string + modelActual string + latency int + runCost float64 // billed THIS run (0 on a checkpoint hit) + cumCost float64 // this attempt's cost (checkpoint cost on a hit, fresh cost on a call) + freshCall bool // a provider call was made this run +} + +// runAttempt executes exactly one attempt on the request-hash axis: a checkpoint +// hit is classified for free (self-heal, incl. legacy Фаза-0 empty/decode +// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+ +// checkpoint. It returns an error only on an infra failure; a bad completion +// comes back as a classification on the attempt. +func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message) (stageAttempt, error) { + reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, st.Model, + st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs) + att := stageAttempt{reqHash: reqHash, modelActual: st.Model} + + // Resume on the attempt axis: a checkpoint means THIS attempt already happened + // and was billed — classify its text and never re-bill (kill -9 loses ≤1 call; + // legacy empty/decode checkpoints self-heal by classification, not a crash). + if cp, err := r.Store.GetCheckpoint(reqHash); err != nil { + return att, err + } else if cp != nil { + var usage llm.Usage + _ = json.Unmarshal([]byte(cp.UsageJSON), &usage) + att.usage = usage + att.text = cp.ResponseText + att.finish = cp.FinishReason + att.modelActual = cp.ModelActual + att.cumCost = cp.CostUSD + att.cls = classify(classifyInput{Source: ch.Text, Output: cp.ResponseText, Finish: cp.FinishReason, TargetLang: r.Book.TargetLang}) + r.Store.LogRequest(ctx, r.Log, store.RequestLog{ + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, + Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual, + RequestHash: reqHash, TMHit: true, OK: att.cls.ok(), FinishReason: cp.FinishReason, + Degraded: degradedTag(att.cls), + }) + _ = r.Store.SetJobStatus(job.ID, "done") + r.Log.InfoContext(ctx, "attempt served from checkpoint", "stage", st.Name, + "attempt", attempt, "hash", reqHash[:12], "disposition", string(att.cls.Reason.disposition())) + return att, nil + } + + // Fresh call: reserve → call → settle+checkpoint (atomic, §3.3). price := r.Pricer.PriceFor(st.Model) promptEst := 0 for _, m := range msgs { @@ -394,27 +625,29 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c }) if err != nil { _ = r.Store.SetJobStatus(job.ID, "failed") - return nil, err + return att, err } switch verdict { case store.ReserveDeniedBook: - // Джоба остаётся 'pending' (не 'failed'): поднимут потолок — продолжит. - return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD) + // Ceiling is a hard, book-wide stop (not a per-chunk flag): the job stays + // 'pending' and resume continues once the ceiling is raised. + return att, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD) case store.ReserveDeniedDay: - return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD) + return att, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD) } client, err := r.client(st.Model) if err != nil { _ = r.Store.Release(resv) _ = r.Store.SetJobStatus(job.ID, "failed") - return nil, err + return att, err } - if err := r.Store.SetJobStatus(job.ID, "running"); err != nil { _ = r.Store.Release(resv) - return nil, err + return att, err } + att.freshCall = true + start := time.Now() resp, err := client.Complete(ctx, llm.LLMRequest{ Model: st.Model, @@ -423,55 +656,64 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c Temperature: st.Temperature, ReasoningEffort: st.Reasoning, }) - latency := int(time.Since(start).Milliseconds()) + att.latency = int(time.Since(start).Milliseconds()) if err != nil { - // 2xx с нечитаемым телом: провайдер УЖЕ списал деньги — резерв не - // возвращаем, консервативно селтлим ОЦЕНКУ с пустым чекпоинтом - // (переучёт ≤1 оценки безопаснее слепого потолка) и падаем громко. var bde *llm.BilledDecodeError if errors.As(err, &bde) { + // 2xx with an unreadable body: the provider ALREADY billed. Do not + // release; conservatively settle the ESTIMATE with an empty decode + // checkpoint, then FLAG (anti-wedge #2 — the loop continues, this is + // not an infra crash). A settle failure here is a real infra fault. if serr := r.Store.SettleWithCheckpoint(resv, estimate, store.Checkpoint{ - RequestHash: reqHash, JobID: job.ID, ChunkIdx: chunkIdx, Attempt: attempt, + RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt, Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: st.Model, - ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: "decode_error", + ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: decodeErrorFinish, }); serr != nil { - r.Log.ErrorContext(ctx, "settle after billed decode failure also failed", "err", serr) + return att, fmt.Errorf("pipeline: settle after billed decode failure: %w", serr) } + att.finish = decodeErrorFinish + att.cumCost, att.runCost = estimate, estimate + att.cls = classification{FlagDecodeError, "billed 2xx with an unreadable body: " + err.Error()} r.Store.LogRequest(ctx, r.Log, store.RequestLog{ - BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name, Role: st.Role, ModelRequested: st.Model, - RequestHash: reqHash, CostUSD: estimate, LatencyMS: latency, - Degraded: "billed_2xx_decode_failed", Err: err.Error(), OK: false, + RequestHash: reqHash, CostUSD: estimate, LatencyMS: att.latency, + Degraded: "billed_2xx_decode_failed", Err: err.Error(), OK: false, FinishReason: decodeErrorFinish, }) - _ = r.Store.SetJobStatus(job.ID, "failed") - return nil, fmt.Errorf("pipeline: stage %s: %w (оценка $%.6f заселтлена консервативно)", st.Name, err, estimate) + _ = r.Store.SetJobStatus(job.ID, "done") + return att, nil } - // No 2xx ever arrived: nothing was billed by the provider — release the - // reservation and surface the failure with a telemetry row. + // No 2xx ever arrived: nothing was billed. Release and surface as an INFRA + // failure — a long book pauses/resumes on an outage or terminal 4xx (D4), + // rather than flag-storming every remaining chunk on a dead provider. if rerr := r.Store.Release(resv); rerr != nil { r.Log.ErrorContext(ctx, "release after failed call also failed", "err", rerr) } r.Store.LogRequest(ctx, r.Log, store.RequestLog{ - BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name, Role: st.Role, ModelRequested: st.Model, - RequestHash: reqHash, LatencyMS: latency, Err: err.Error(), OK: false, + RequestHash: reqHash, LatencyMS: att.latency, Err: err.Error(), OK: false, }) _ = r.Store.SetJobStatus(job.ID, "failed") - return nil, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err) + return att, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err) } modelActual := resp.Model if modelActual == "" { modelActual = st.Model } + att.modelActual = modelActual + att.text = resp.Text + att.finish = resp.FinishReason + att.usage = resp.Usage + // Цена по фактически ответившей модели, с fallback на ЗАПРОШЕННУЮ (не на // дешёвый глобальный якорь), если провайдер вернул канонизированный слаг. price = r.Pricer.PriceForResponse(st.Model, modelActual) cost := ledger.CostUSD(price, resp.Usage) - // Платная модель (InputPerM>0) вернула 2xx с нулевым usage — баг учёта - // провайдера/необычный ответ: $0 ослепил бы потолок, поэтому берём - // консервативную оценку и предупреждаем (находка ревью). Для local ($0 - // цена) нулевой usage штатен — остаётся $0. + // Платная модель (InputPerM>0) вернула 2xx с нулевым usage — $0 ослепил бы + // потолок: берём консервативную оценку. Для local ($0 цена) нулевой usage + // штатен — остаётся $0. if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 { r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest", "stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate)) @@ -479,12 +721,15 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c } usageJSON, err := json.Marshal(resp.Usage) if err != nil { - return nil, err + return att, err } + att.cumCost, att.runCost = cost, cost - // Деньги: settle + сырой ответ — одна транзакция (§3.3). + // Деньги: settle + сырой ответ — одна транзакция (§3.3). ВСЕГДА, даже для + // пустого/усечённого/отказного ответа: он оплачен провайдером, чекпоинт + // хранит его до цента, а годность решает classify ПОСЛЕ (деньги ≠ вердикт). if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{ - RequestHash: reqHash, JobID: job.ID, ChunkIdx: chunkIdx, Attempt: attempt, + RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt, Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual, ResponseText: resp.Text, UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason, @@ -492,45 +737,85 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c }); err != nil { // The provider HAS billed this 2xx; failing to persist means the money // state is behind reality — fail loud, never continue on top. - return nil, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err) + return att, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err) } - // Пустой оплаченный ответ (finish=length: thinking съел бюджет; фильтр): - // деньги заселтлены, чекпоинт есть — но дальше по конвейеру его НЕ подаём: - // черновик="" молча отравил бы редактуру. Громкая остановка (регенерация - // по attempt — Фаза 1). - if strings.TrimSpace(resp.Text) == "" { - r.Store.LogRequest(ctx, r.Log, store.RequestLog{ - BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, - Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual, - RequestHash: reqHash, - PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens, - CacheCreationTokens: resp.Usage.CacheCreationTokens, - CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens, - CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason, - Degraded: "empty_completion", OK: false, - }) - _ = r.Store.SetJobStatus(job.ID, "failed") - return nil, fmt.Errorf("pipeline: stage %s returned an EMPTY completion (finish=%s, billed $%.6f, checkpointed) — вероятно thinking/фильтр съел бюджет; поднимите max_tokens/смените модель и удалите строку checkpoints %.12s", st.Name, resp.FinishReason, cost, reqHash) - } + // Classify AFTER the money is durably settled+checkpointed. F4 lives here: a + // non-empty truncated length draft is now classified (flagged/retried), never + // silently passed downstream as OK; an empty completion is flagged too, not a + // run-crash. + att.cls = classify(classifyInput{Source: ch.Text, Output: resp.Text, Finish: resp.FinishReason, TargetLang: r.Book.TargetLang}) r.Store.LogRequest(ctx, r.Log, store.RequestLog{ - BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual, - RequestHash: reqHash, + RequestHash: reqHash, PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens, CacheCreationTokens: resp.Usage.CacheCreationTokens, CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens, - CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason, OK: true, + CostUSD: cost, LatencyMS: att.latency, FinishReason: resp.FinishReason, + OK: att.cls.ok(), Degraded: degradedTag(att.cls), }) - if err := r.Store.SetJobStatus(job.ID, "done"); err != nil { - return nil, err - } - r.Log.InfoContext(ctx, "stage completed", "stage", st.Name, "model", modelActual, - "cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", latency, - "prompt_tokens", resp.Usage.PromptTokens, "completion_tokens", resp.Usage.CompletionTokens) - - return &StageResult{Stage: st.Name, Role: st.Role, Model: modelActual, - Usage: resp.Usage, CostUSD: cost, LatencyMS: latency, - FinishReason: resp.FinishReason, Text: resp.Text}, nil + _ = r.Store.SetJobStatus(job.ID, "done") + r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual, + "cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", att.latency, + "disposition", string(att.cls.Reason.disposition()), "reason", string(att.cls.Reason)) + return att, nil +} + +// resumeFromChunkStatus serves a chunk×stage whose disposition is already +// resolved (read before any render — no re-billing). An ok stage returns its +// authoritative checkpoint text to feed the next stage; a flagged stage returns +// the flag WITHOUT re-attacking (a terminal flag never re-enters the paid loop). +func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch Chunk, cs *store.ChunkStatus) (*StageResult, error) { + sr := &StageResult{ + Stage: st.Name, Role: st.Role, Model: st.Model, FromResume: true, + CostUSD: 0, CumCostUSD: cs.CostUSD, + Disposition: Disposition(cs.Disposition), FlagReason: FlagReason(cs.FlagReason), + Detail: cs.Detail, Attempts: cs.Attempts, + } + if cs.Disposition == string(DispOK) { + cp, err := r.Store.GetCheckpoint(cs.FinalHash) + if err != nil { + return nil, err + } + if cp == nil || strings.TrimSpace(cp.ResponseText) == "" { + // chunk_status ok is only written AFTER its checkpoint is durably + // settled, so this is a torn/tampered store — fail loud rather than + // feed the next stage an empty draft. + return nil, fmt.Errorf("pipeline: chunk_status ok for %s/ch%d/chunk%d/%s references checkpoint %.12s which is missing/empty — inconsistent store", + r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, cs.FinalHash) + } + var usage llm.Usage + _ = json.Unmarshal([]byte(cp.UsageJSON), &usage) + sr.Usage = usage + sr.Model = cp.ModelActual + sr.FinishReason = cp.FinishReason + sr.Text = cp.ResponseText + } + r.Store.LogRequest(ctx, r.Log, store.RequestLog{ + BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, + Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: sr.Model, + RequestHash: cs.FinalHash, TMHit: true, OK: cs.Disposition == string(DispOK), + FinishReason: sr.FinishReason, Degraded: nonOKTag(cs.Disposition, cs.FlagReason), + }) + r.Log.InfoContext(ctx, "stage resolved from chunk_status", "stage", st.Name, + "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "disposition", cs.Disposition, "reason", cs.FlagReason) + return sr, nil +} + +// degradedTag surfaces a flag reason into the request_log `degraded` column. +func degradedTag(cls classification) string { + if cls.ok() { + return "" + } + return string(cls.Reason) +} + +// nonOKTag is the request_log `degraded` value for a resumed disposition row. +func nonOKTag(disposition, reason string) string { + if disposition == string(DispOK) { + return "" + } + return reason } diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go index c0acc7a..163d12e 100644 --- a/backend/internal/pipeline/runner_test.go +++ b/backend/internal/pipeline/runner_test.go @@ -1,8 +1,9 @@ package pipeline import ( - "bytes" "context" + "database/sql" + "encoding/json" "fmt" "io" "net/http" @@ -10,16 +11,18 @@ import ( "os" "path/filepath" "strings" - "sync/atomic" + "sync" "testing" "time" "textmachine/backend/internal/obs" + + _ "modernc.org/sqlite" ) -// e2e-тест мини-раннера: temp-проект книги против httptest-провайдера. Деньги, -// чекпоинты и resume проверяются через реальные конфиги/store — то, что -// демонстрирует приёмка Фазы 0, только на моке. +// e2e-тесты книжного раннера Вехи 2: temp-проект против httptest-провайдера. +// Деньги, чекпоинты, resume, disposition и exit-коды проверяются через реальные +// конфиги/store — то, что демонстрирует приёмка, только на моке. func writeFile(t *testing.T, path, content string) { t.Helper() @@ -31,25 +34,89 @@ func writeFile(t *testing.T, path, content string) { } } -// newFakeProvider serves OpenAI-совместимые ответы: draft-стадии отвечает -// «ЧЕРНОВИК», edit-стадии — «РЕДАКТУРА» (различает по наличию слова ЧЕРНОВИК -// в промпте редактора). Считает вызовы. -func newFakeProvider(t *testing.T, calls *atomic.Int32) *httptest.Server { +// reqRec records every provider request body (thread-safe for -race). +type reqRec struct { + mu sync.Mutex + n int + bodies []string +} + +func (r *reqRec) record(body string) { + r.mu.Lock() + defer r.mu.Unlock() + r.n++ + r.bodies = append(r.bodies, body) +} +func (r *reqRec) count() int { r.mu.Lock(); defer r.mu.Unlock(); return r.n } +func (r *reqRec) all() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.bodies...) +} + +// newJSONProvider serves an OpenAI-compatible completion whose text+finish come +// from respond(body); usage is fixed. The default draftEdit distinguishes the +// editor by the presence of the editor-prompt marker in the body. +func newJSONProvider(rec *reqRec, respond func(body string) (text, finish string)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) body, _ := io.ReadAll(r.Body) - text := "ЧЕРНОВИК ПЕРЕВОДА" - if bytes.Contains(body, []byte("Черновик перевода для редактуры")) { - text = "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" + rec.record(string(body)) + text, finish := respond(string(body)) + if finish == "" { + finish = "stop" } - fmt.Fprintf(w, `{"id":"fake-%d","model":"fake-model","choices":[{"message":{"content":"%s"},"finish_reason":"stop"}], + tb, _ := json.Marshal(text) + fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":%q}], "usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, - calls.Load(), text) + tb, finish) })) } -func setupProject(t *testing.T, providerURL string) string { +func isEditBody(body string) bool { + return strings.Contains(body, "Черновик перевода для редактуры") +} + +func draftEdit(body string) (string, string) { + if isEditBody(body) { + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + } + return "ЧЕРНОВИК ПЕРЕВОДА", "stop" +} + +func maxTokensOf(t *testing.T, body string) int { t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("bad request body: %v", err) + } + v, ok := m["max_tokens"].(float64) + if !ok { + t.Fatalf("no numeric max_tokens in body: %s", body) + } + return int(v) +} + +// перстоимость fake-model на вызов: (1000-200)·1 + 200·0.1 + 500·2 за 1M. +const fakeCallUSD = (800*1.0 + 200*0.1 + 500*2.0) / 1e6 + +type projectOpts struct { + source string + regenerate int + minMaxTokens int + bookUSD float64 +} + +func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string { + t.Helper() + if o.source == "" { + o.source = "静かな図書館の朝。" + } + if o.minMaxTokens == 0 { + o.minMaxTokens = 512 + } + if o.bookUSD == 0 { + o.bookUSD = 1.0 + } dir := t.TempDir() writeFile(t, filepath.Join(dir, "prompts", "translator.md"), @@ -71,18 +138,18 @@ models: price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } `, time.Now().UTC().Format("2006-01-02"), providerURL)) - writeFile(t, filepath.Join(dir, "pipeline.yaml"), ` + writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(` core: C1 version: 1 -defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } -retries: { regenerate_before_escalate: 1 } +defaults: { max_output_ratio: 2.0, min_max_tokens: %d } +retries: { regenerate_before_escalate: %d } stages: - { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" } - { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" } -`) +`, o.minMaxTokens, o.regenerate)) - writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。") - writeFile(t, filepath.Join(dir, "book.yaml"), ` + writeFile(t, filepath.Join(dir, "source.txt"), o.source) + writeFile(t, filepath.Join(dir, "book.yaml"), fmt.Sprintf(` book_id: test-book title: Тест source_lang: ja @@ -96,37 +163,50 @@ footnotes: minimal pipeline: pipeline.yaml models: models.yaml source_file: source.txt -ceilings: { book_usd: 1.0, day_usd: 2.0 } -`) +ceilings: { book_usd: %g, day_usd: 2.0 } +`, o.bookUSD)) return filepath.Join(dir, "book.yaml") } +func setupProject(t *testing.T, providerURL string) string { + return setupProjectOpts(t, providerURL, projectOpts{regenerate: 1}) +} + +func newRunner(t *testing.T, bookPath string) *Runner { + t.Helper() + r, err := NewRunner(bookPath, obs.NewLogger()) + if err != nil { + t.Fatal(err) + } + return r +} + +// --- existing Фаза-0/Веха-1 guarantees, ported to the book runner ----------- + func TestRunnerEndToEndWithResume(t *testing.T) { - var calls atomic.Int32 - srv := newFakeProvider(t, &calls) + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) - // Прогон 1: два вызова (draft, edit), деньги учтены. - r1, err := NewRunner(bookPath, obs.NewLogger()) + r1 := newRunner(t, bookPath) + res1, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } - res1, err := r1.TranslateOneChunk(ctx) - if err != nil { - t.Fatal(err) + if rec.count() != 2 { + t.Fatalf("expected 2 provider calls, got %d", rec.count()) } - if calls.Load() != 2 { - t.Fatalf("expected 2 provider calls, got %d", calls.Load()) - } - if len(res1.Stages) != 2 || res1.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { + if len(res1.Chunks) != 1 || res1.Flagged != 0 || res1.ExitCode() != 0 { t.Fatalf("run1 = %+v", res1) } - // Стоимость по формуле: (1000-200)*1 + 200*0.1 + 500*2 за 1M — на стадию. - wantStage := (800*1.0 + 200*0.1 + 500*2.0) / 1e6 - if diff := res1.TotalUSD - 2*wantStage; diff > 1e-12 || diff < -1e-12 { - t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*wantStage) + ch := res1.Chunks[0] + if ch.Disposition != DispOK || ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { + t.Fatalf("chunk = %+v", ch) + } + if diff := res1.TotalUSD - 2*fakeCallUSD; diff > 1e-12 || diff < -1e-12 { + t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*fakeCallUSD) } committed, reserved, err := r1.Store.SpentUSD("test-book") if err != nil { @@ -137,121 +217,91 @@ func TestRunnerEndToEndWithResume(t *testing.T) { } r1.Close() - // Прогон 2 (новый процесс — новый Runner над тем же project.db): оба - // вызова обслуживаются чекпоинтами, провайдер не трогается, доплаты нет. - r2, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } + // Прогон 2 (новый процесс): обе стадии из чекпоинтов/chunk_status за $0. + r2 := newRunner(t, bookPath) defer r2.Close() - res2, err := r2.TranslateOneChunk(ctx) + res2, err := r2.TranslateBook(ctx) if err != nil { t.Fatal(err) } - if calls.Load() != 2 { - t.Fatalf("resume must not call the provider again, calls=%d", calls.Load()) + if rec.count() != 2 { + t.Fatalf("resume must not call the provider again, calls=%d", rec.count()) } - for _, st := range res2.Stages { + if res2.TotalUSD != 0 { + t.Fatalf("resume must be $0, got %v", res2.TotalUSD) + } + for _, st := range res2.Chunks[0].Stages { if !st.FromResume || st.CostUSD != 0 { - t.Fatalf("stage %s must be served from checkpoint at $0: %+v", st.Stage, st) + t.Fatalf("stage %s must be served from resume at $0: %+v", st.Stage, st) } } - if res2.FinalText != res1.FinalText { - t.Fatalf("resume text differs: %q vs %q", res2.FinalText, res1.FinalText) - } - committed2, _, err := r2.Store.SpentUSD("test-book") - if err != nil { - t.Fatal(err) + if res2.Chunks[0].FinalText != res1.Chunks[0].FinalText { + t.Fatalf("resume text differs: %q vs %q", res2.Chunks[0].FinalText, res1.Chunks[0].FinalText) } + committed2, _, _ := r2.Store.SpentUSD("test-book") if committed2 != committed { t.Fatalf("resume must not add spend: %v -> %v", committed, committed2) } } -// Р6: правка конфига/промпта после старта джоб — не тихая инвалидация -// чекпоинтов, а громкая ошибка; пере-перевод только явным --resnapshot. func TestRunnerSnapshotPinning(t *testing.T) { - var calls atomic.Int32 - srv := newFakeProvider(t, &calls) + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) ctx := context.Background() - r1, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } - if _, err := r1.TranslateOneChunk(ctx); err != nil { + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() - if calls.Load() != 2 { - t.Fatalf("run1 calls = %d", calls.Load()) + if rec.count() != 2 { + t.Fatalf("run1 calls = %d", rec.count()) } - // Меняем промпт переводчика → новый snapshot. promptPath := filepath.Join(filepath.Dir(bookPath), "prompts", "translator.md") writeFile(t, promptPath, "НОВЫЙ промпт с {{source_lang}}.\n---USER---\n{{text}}") - r2, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } - _, err = r2.TranslateOneChunk(ctx) + r2 := newRunner(t, bookPath) + _, err := r2.TranslateBook(ctx) r2.Close() if err == nil || !strings.Contains(err.Error(), "resnapshot") { t.Fatalf("changed config must fail loud mentioning --resnapshot, got: %v", err) } - if calls.Load() != 2 { - t.Fatalf("denied resume must not call the provider, calls=%d", calls.Load()) + if rec.count() != 2 { + t.Fatalf("denied resume must not call the provider, calls=%d", rec.count()) } - // Явное согласие: джобы перепривязываются, вызовы повторяются и оплачиваются. - r3, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } + r3 := newRunner(t, bookPath) defer r3.Close() r3.Resnapshot = true - res, err := r3.TranslateOneChunk(ctx) + res, err := r3.TranslateBook(ctx) if err != nil { t.Fatal(err) } - if calls.Load() != 4 { - t.Fatalf("resnapshot run must re-call both stages, calls=%d", calls.Load()) + if rec.count() != 4 { + t.Fatalf("resnapshot run must re-call both stages, calls=%d", rec.count()) } if res.TotalUSD <= 0 { t.Fatal("re-translation must be billed") } } -// D5.2/B: правка ТОЛЬКО каппы модели (wire-форма) обязана сдвинуть snapshot и -// уронить resume громко — иначе изменённое тело запроса подалось бы из старого -// чекпоинта (тихий расходящийся ре-пэй). Гейт на то, что stageSnap реально -// сворачивает резолвнутую Capability: без `ss.Capability` этот тест бы прошёл -// resume молча (находка селфревью вехи 1). +// D5.2/B: правка ТОЛЬКО каппы модели двигает snapshot и роняет resume громко. func TestRunnerSnapshotPinsCapability(t *testing.T) { - var calls atomic.Int32 - srv := newFakeProvider(t, &calls) + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) ctx := context.Background() - r1, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } - if _, err := r1.TranslateOneChunk(ctx); err != nil { + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() - if calls.Load() != 2 { - t.Fatalf("run1 calls = %d", calls.Load()) - } - // Меняем ТОЛЬКО каппу fake-model (temperature force) — промпт/сэмплинг/ - // провайдер/цены те же. Резолвнутая Capability меняется → snapshotID обязан - // сдвинуться → resume падает на snapshot-pinning, а не подаёт чекпоинт. modelsPath := filepath.Join(filepath.Dir(bookPath), "models.yaml") raw, err := os.ReadFile(modelsPath) if err != nil { @@ -265,22 +315,17 @@ func TestRunnerSnapshotPinsCapability(t *testing.T) { } writeFile(t, modelsPath, patched) - r2, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } - _, err = r2.TranslateOneChunk(ctx) + r2 := newRunner(t, bookPath) + _, err = r2.TranslateBook(ctx) r2.Close() if err == nil || !strings.Contains(err.Error(), "resnapshot") { - t.Fatalf("a capability edit must fail loud mentioning --resnapshot (snapshot must fold the resolved capability), got: %v", err) + t.Fatalf("a capability edit must fail loud mentioning --resnapshot, got: %v", err) } - if calls.Load() != 2 { - t.Fatalf("denied resume must not call the provider, calls=%d", calls.Load()) + if rec.count() != 2 { + t.Fatalf("denied resume must not call the provider, calls=%d", rec.count()) } } -// Платный 2xx с нулевым usage не должен селтлиться в $0 (иначе потолок слепнет): -// берётся консервативная оценка резерва. func TestRunnerZeroUsagePaidSettlesEstimate(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"id":"z","model":"deepseek-v4-flash","choices":[{"message":{"content":"перевод"},"finish_reason":"stop"}], @@ -289,36 +334,27 @@ func TestRunnerZeroUsagePaidSettlesEstimate(t *testing.T) { defer srv.Close() bookPath := setupProject(t, srv.URL) - r, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } + r := newRunner(t, bookPath) defer r.Close() - res, err := r.TranslateOneChunk(context.Background()) + res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } if res.TotalUSD <= 0 { t.Fatalf("zero-usage paid 2xx must settle a non-zero estimate, got $%.6f", res.TotalUSD) } - committed, _, err := r.Store.SpentUSD("test-book") - if err != nil { - t.Fatal(err) - } + committed, _, _ := r.Store.SpentUSD("test-book") if committed != res.TotalUSD { t.Fatalf("committed %v != total %v", committed, res.TotalUSD) } } -// NewRunner обязан отклонить конфиг с нереализованной механикой ДО открытия -// store и любых вызовов (guard границы «конфиг vs код»). func TestRunnerRejectsUnrunnableConfig(t *testing.T) { - var calls atomic.Int32 - srv := newFakeProvider(t, &calls) + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) - // Ломаем pipeline на fanout>1 (механика Фазы 2). pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") raw, err := os.ReadFile(pipePath) if err != nil { @@ -329,35 +365,449 @@ func TestRunnerRejectsUnrunnableConfig(t *testing.T) { if _, err := NewRunner(bookPath, obs.NewLogger()); err == nil { t.Fatal("fanout.candidates>1 must be rejected by NewRunner (Phase-2 mechanics)") } - if calls.Load() != 0 { - t.Fatalf("rejected config must not reach the provider, calls=%d", calls.Load()) + if rec.count() != 0 { + t.Fatalf("rejected config must not reach the provider, calls=%d", rec.count()) } } func TestRunnerCeilingDenies(t *testing.T) { - var calls atomic.Int32 - srv := newFakeProvider(t, &calls) + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) defer srv.Close() - bookPath := setupProject(t, srv.URL) + bookPath := setupProjectOpts(t, srv.URL, projectOpts{bookUSD: 0.0000001}) - // Потолок $0: первый же reserve обязан отказать ДО вызова провайдера. - raw, err := os.ReadFile(bookPath) - if err != nil { - t.Fatal(err) - } - patched := strings.Replace(string(raw), "book_usd: 1.0", "book_usd: 0.0000001", 1) - writeFile(t, bookPath, patched) - - r, err := NewRunner(bookPath, obs.NewLogger()) - if err != nil { - t.Fatal(err) - } + r := newRunner(t, bookPath) defer r.Close() - _, err = r.TranslateOneChunk(context.Background()) + _, err := r.TranslateBook(context.Background()) if err == nil { t.Fatal("ceiling must deny the run") } - if calls.Load() != 0 { - t.Fatalf("denied reserve must not reach the provider, calls=%d", calls.Load()) + if rec.count() != 0 { + t.Fatalf("denied reserve must not reach the provider, calls=%d", rec.count()) + } +} + +// --- Веха 2: chunk loop, disposition, resume, exit codes -------------------- + +// Multi-chunk (two chapters via form feed) end-to-end + resume at $0. +func TestRunnerMultiChunkResume(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАОДИН\fГЛАВАДВА"}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + res1, err := r1.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + r1.Close() + if len(res1.Chunks) != 2 { + t.Fatalf("want 2 chunks (2 chapters), got %d", len(res1.Chunks)) + } + if res1.Chunks[0].Chapter != 1 || res1.Chunks[1].Chapter != 2 { + t.Fatalf("chapters = %d,%d", res1.Chunks[0].Chapter, res1.Chunks[1].Chapter) + } + if rec.count() != 4 { // 2 chunks × (draft+edit) + t.Fatalf("want 4 calls, got %d", rec.count()) + } + + r2 := newRunner(t, bookPath) + defer r2.Close() + res2, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if rec.count() != 4 { + t.Fatalf("resume must not re-call, calls=%d", rec.count()) + } + if res2.TotalUSD != 0 || res2.Flagged != 0 || res2.ExitCode() != 0 { + t.Fatalf("resume run = %+v", res2) + } +} + +// D2 core: a flagged chunk skips its remaining stages and the loop continues to +// the next chunk; the run completes with exit code 2, and resume does not +// re-attack the flagged chunk. +func TestRunnerFlagSkipContinueExit2(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) { + return "Извините, я не могу перевести это.", "stop" // soft refusal + } + return draftEdit(body) + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА"}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + res1, err := r1.TranslateBook(ctx) + if err != nil { + t.Fatalf("a flagged chunk must NOT be an error (loop continues), got %v", err) + } + // draft(ch1)=refusal → edit(ch1) skipped (no call); draft(ch2)+edit(ch2) ok = 3 calls. + if rec.count() != 3 { + t.Fatalf("want 3 calls (edit of flagged chunk skipped), got %d", rec.count()) + } + if res1.Flagged != 1 || res1.ExitCode() != 2 { + t.Fatalf("want 1 flagged chunk / exit 2, got flagged=%d exit=%d", res1.Flagged, res1.ExitCode()) + } + c1 := res1.Chunks[0] + if c1.Disposition != DispFlagged || c1.FlagReason != FlagSoftRefusal || c1.FinalText != "" { + t.Fatalf("chunk1 = %+v (want flagged/soft_refusal/no text)", c1) + } + if c1.Stages[1].Disposition != DispSkipped { + t.Fatalf("edit of a flagged chunk must be skipped, got %+v", c1.Stages[1]) + } + if res1.Chunks[1].Disposition != DispOK { + t.Fatalf("chunk2 must be ok, got %+v", res1.Chunks[1]) + } + // chunk_status persisted the flag and the skip. + cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft") + if cs == nil || cs.Disposition != "flagged" || cs.FlagReason != "soft_refusal" { + t.Fatalf("draft chunk_status = %+v", cs) + } + skip, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit") + if skip == nil || skip.Disposition != "skipped" { + t.Fatalf("edit chunk_status = %+v", skip) + } + r1.Close() + + // Resume: the flagged chunk is resolved from chunk_status, NOT re-attacked. + r2 := newRunner(t, bookPath) + defer r2.Close() + res2, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if rec.count() != 3 { + t.Fatalf("resume must not re-attack a flagged chunk, calls=%d", rec.count()) + } + if res2.Flagged != 1 || res2.TotalUSD != 0 || res2.ExitCode() != 2 { + t.Fatalf("resume run = %+v", res2) + } +} + +// F4: a non-empty truncated (finish=length) draft must be FLAGGED, never passed +// downstream to the editor as OK. With no regenerations it flags immediately. +func TestRunnerF4TruncatedLengthNotPassedToEdit(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if !isEditBody(body) { + return "Обрыв на середине предложения", "length" // truncated, non-empty, not a loop + } + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) + ctx := context.Background() + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(ctx) + if err != nil { + t.Fatalf("a truncated draft must flag, not crash: %v", err) + } + if rec.count() != 1 { + t.Fatalf("edit must NOT run on a flagged draft, calls=%d", rec.count()) + } + ch := res.Chunks[0] + if ch.Disposition != DispFlagged || ch.FlagReason != FlagLength { + t.Fatalf("chunk must be flagged length, got %+v", ch) + } + if ch.Stages[0].Disposition != DispFlagged || ch.Stages[1].Disposition != DispSkipped { + t.Fatalf("draft flagged + edit skipped expected, got %+v", ch.Stages) + } +} + +// D2.3 + F3 + attempt-axis: a length cut is retried with a DOUBLED budget; the +// second attempt succeeds, the editor then runs, and cost sums both attempts. +func TestRunnerLengthRetrySucceedsDoublesBudget(t *testing.T) { + rec := &reqRec{} + src := strings.Repeat("字", 500) // EstimateTokens=500 → base max_tokens=1000 + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + } + if maxTokensOf(t, body) <= 1000 { // attempt 0 (base) + return "Обрыв на середине", "length" + } + return "ПОЛНЫЙ ЧЕРНОВИК", "stop" // attempt 1 (doubled budget) + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 1, minMaxTokens: 128}) + ctx := context.Background() + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + ch := res.Chunks[0] + if ch.Disposition != DispOK || ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { + t.Fatalf("retry should succeed and edit should run, got %+v", ch) + } + draft := ch.Stages[0] + if draft.Attempts != 2 { + t.Fatalf("draft must have taken 2 attempts, got %d", draft.Attempts) + } + // F3: cost sums BOTH attempts. + if diff := draft.CumCostUSD - 2*fakeCallUSD; diff > 1e-12 || diff < -1e-12 { + t.Fatalf("draft cum cost = %v, want 2×call %v (attempts summed)", draft.CumCostUSD, 2*fakeCallUSD) + } + // The two draft attempts doubled the budget: 1000 then 2000. + var draftToks []int + for _, b := range rec.all() { + if !isEditBody(b) { + draftToks = append(draftToks, maxTokensOf(t, b)) + } + } + if len(draftToks) != 2 || draftToks[0] != 1000 || draftToks[1] != 2000 { + t.Fatalf("attempt budgets = %v, want [1000 2000]", draftToks) + } +} + +// D2.5: the editor's max_tokens is sized from the (long Russian) DRAFT, not the +// (short CJK) source — otherwise it under-budgets and false-triggers a length +// retry. Broken D2.5 would make the two budgets equal. +func TestRunnerEditSizedFromDraft(t *testing.T) { + rec := &reqRec{} + longDraft := strings.Repeat("Утро было тихим и ясным над рекой. ", 120) // long Russian output + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "ОК", "stop" + } + return longDraft, "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: strings.Repeat("字", 100), minMaxTokens: 128}) + + r := newRunner(t, bookPath) + defer r.Close() + if _, err := r.TranslateBook(context.Background()); err != nil { + t.Fatal(err) + } + var draftTok, editTok int + for _, b := range rec.all() { + if isEditBody(b) { + editTok = maxTokensOf(t, b) + } else { + draftTok = maxTokensOf(t, b) + } + } + if editTok <= draftTok { + t.Fatalf("edit max_tokens (%d) must exceed draft's (%d) — sized from the longer draft (D2.5)", editTok, draftTok) + } +} + +// Anti-wedge #2: an empty completion is FLAGGED and the loop continues — not a +// crash (the Фаза-0 behaviour was to error out the whole run). +func TestRunnerEmptyFlaggedNotCrash(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if !isEditBody(body) { + return "", "stop" // empty draft + } + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(context.Background()) + if err != nil { + t.Fatalf("empty completion must flag, not crash: %v", err) + } + ch := res.Chunks[0] + if ch.Disposition != DispFlagged || ch.FlagReason != FlagEmpty { + t.Fatalf("empty draft must be flagged empty, got %+v", ch) + } + if res.ExitCode() != 2 { + t.Fatalf("exit code must be 2, got %d", res.ExitCode()) + } + // Money for the paid-but-empty call is still accounted. + committed, _, _ := r.Store.SpentUSD("test-book") + if committed <= 0 { + t.Fatalf("the paid empty call must still be billed, committed=%v", committed) + } +} + +// Anti-wedge #2: a billed 2xx with an unreadable body → flagged decode_error +// with the estimate settled, loop continues (not a crash). +func TestRunnerDecodeErrorFlagged(t *testing.T) { + var draftCalls int + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !isEditBody(string(body)) { + mu.Lock() + draftCalls++ + mu.Unlock() + fmt.Fprint(w, `NOT JSON AT ALL`) // 200 with a garbage body → BilledDecodeError + return + } + fmt.Fprint(w, `{"id":"e","model":"fake-model","choices":[{"message":{"content":"ред"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) + })) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(context.Background()) + if err != nil { + t.Fatalf("a billed decode failure must flag, not crash: %v", err) + } + ch := res.Chunks[0] + if ch.Disposition != DispFlagged || ch.FlagReason != FlagDecodeError { + t.Fatalf("draft must be flagged decode_error, got %+v", ch) + } + if ch.Stages[1].Disposition != DispSkipped { + t.Fatalf("edit must be skipped after a decode flag, got %+v", ch.Stages[1]) + } + if res.TotalUSD <= 0 { + t.Fatal("the conservative estimate must be settled for a billed decode failure") + } +} + +// Determinism content-guard (self-review Веха 2): the source is NOT in the +// snapshot, so editing a chunk's source between runs must RE-TRANSLATE that +// position (content-addressed, §3.4), never serve the stale positional +// chunk_status row. Without the content_hash guard the fast-path would return +// the OLD translation at $0. +func TestRunnerSourceEditReTranslates(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "СТАРЫЙИСХОДНИК"}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + callsAfterRun1 := rec.count() + + // Edit the source in place (same position, still one chunk). No brief field + // and not the chunker changed, so the snapshot is unchanged — the ONLY thing + // that must invalidate resume is the content itself. + writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "НОВЫЙИСХОДНИК") + + r2 := newRunner(t, bookPath) + defer r2.Close() + res, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + // Both stages re-render with the new content (the editor sees {{text}} too), + // miss their old checkpoints, and are re-translated and re-billed. + if rec.count() != callsAfterRun1+2 { + t.Fatalf("an edited source must re-translate the chunk, not serve stale: calls %d -> %d", callsAfterRun1, rec.count()) + } + if res.TotalUSD <= 0 { + t.Fatalf("re-translation of an edited source must be billed, got $%.6f", res.TotalUSD) + } +} + +// Anti-wedge #1: a terminally-flagged chunk is resolved from chunk_status BEFORE +// rendering, so it is NOT re-attacked even when the retry budget is later raised +// (RegenerateBeforeEscalate is not in the snapshot). Without the chunk_status +// fast-path the attempt loop would spawn NEW paid attempts up to the new cap. +func TestRunnerFlaggedNotReattackedWhenRegenRaised(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if !isEditBody(body) { + return "Обрыв на середине", "length" // always a length cut + } + return "ред", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + callsAfterRun1 := rec.count() // regen=0 → exactly 1 draft attempt, flagged length + + // Raise the regeneration budget and re-run. The flagged chunk must stay put. + pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") + raw, err := os.ReadFile(pipePath) + if err != nil { + t.Fatal(err) + } + writeFile(t, pipePath, strings.Replace(string(raw), "regenerate_before_escalate: 0", "regenerate_before_escalate: 2", 1)) + + r2 := newRunner(t, bookPath) + defer r2.Close() + res, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if rec.count() != callsAfterRun1 { + t.Fatalf("a terminally-flagged chunk must NOT be re-attacked on a raised budget: calls %d -> %d", callsAfterRun1, rec.count()) + } + if res.Chunks[0].Disposition != DispFlagged || res.Chunks[0].FlagReason != FlagLength { + t.Fatalf("chunk must stay flagged length, got %+v", res.Chunks[0]) + } +} + +// Anti-wedge #3: a checkpoint present without its chunk_status row (kill -9 lost +// the resolve) self-heals — classify the checkpoint text, no re-billing, no +// crash. Proven by deleting the chunk_status row and re-running with regen=0 so +// the empty flag cannot spawn a new paid attempt. +func TestRunnerLegacyEmptyCheckpointSelfHeal(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if !isEditBody(body) { + return "", "stop" // empty draft → empty checkpoint + } + return "ред", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + dbPath := r1.Book.ProjectDB + r1.Close() + callsAfterRun1 := rec.count() + + // Simulate the resolve being lost while the (empty) checkpoint survives. + db, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DELETE FROM chunk_status WHERE stage = 'draft'`); err != nil { + t.Fatal(err) + } + db.Close() + + r2 := newRunner(t, bookPath) + defer r2.Close() + res, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatalf("legacy empty checkpoint must self-heal, not crash: %v", err) + } + if rec.count() != callsAfterRun1 { + t.Fatalf("self-heal must NOT re-bill the empty attempt: calls %d -> %d", callsAfterRun1, rec.count()) + } + ch := res.Chunks[0] + if ch.Disposition != DispFlagged || ch.FlagReason != FlagEmpty { + t.Fatalf("self-heal must re-derive the empty flag, got %+v", ch) + } + // chunk_status was rebuilt. + cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft") + if cs == nil || cs.Disposition != "flagged" { + t.Fatalf("chunk_status must be rebuilt as flagged, got %+v", cs) } } diff --git a/backend/internal/store/chunkstatus.go b/backend/internal/store/chunkstatus.go new file mode 100644 index 0000000..56b83fc --- /dev/null +++ b/backend/internal/store/chunkstatus.go @@ -0,0 +1,106 @@ +package store + +import ( + "database/sql" + "errors" +) + +// chunkstatus.go: the per-chunk×stage disposition record (Веха 2, D2). It is a +// materialized RESOLVE over the append-only checkpoints (keyed by request_hash, +// which already carries `attempt`), NOT a column on checkpoints. Because it is +// derivable from the checkpoints, losing it to a kill -9 self-heals: on resume +// the runner re-classifies the checkpoint text (no re-billing) and re-upserts +// the row. `disposition` is the axis the runner loop reads BEFORE rendering, so +// a terminally-flagged chunk is never re-attacked into an infinite paid loop. +// +// The store is dumb storage here: disposition/flag_reason are plain strings; the +// typed FlagReason/Disposition constants and the classifier live in the pipeline +// package. + +// ChunkStatus is one (book, chapter, chunk, stage) disposition row. +type ChunkStatus struct { + BookID string + Chapter int + ChunkIdx int + Stage string + SnapshotID string + ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit + Disposition string // ok | flagged | skipped + FlagReason string // "" when ok + Attempts int // number of attempts made for this chunk×stage + FinalHash string // request_hash of the authoritative checkpoint (ok path) + CostUSD float64 // sum across all attempts (F3-honest) + Detail string +} + +// UpsertChunkStatus writes (or overwrites) the disposition row. Overwrite is the +// resolve semantics: re-running classify over the same checkpoints reproduces the +// same row, and a --resnapshot re-processing legitimately replaces a stale row. +func (s *Store) UpsertChunkStatus(cs ChunkStatus) error { + ctx, cancel := opContext() + defer cancel() + _, err := s.w.ExecContext(ctx, ` + INSERT INTO chunk_status ( + book_id, chapter, chunk_idx, stage, snapshot_id, content_hash, + disposition, flag_reason, attempts, final_hash, cost_usd, detail, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT (book_id, chapter, chunk_idx, stage) DO UPDATE SET + snapshot_id = excluded.snapshot_id, + content_hash = excluded.content_hash, + disposition = excluded.disposition, + flag_reason = excluded.flag_reason, + attempts = excluded.attempts, + final_hash = excluded.final_hash, + cost_usd = excluded.cost_usd, + detail = excluded.detail, + updated_at = excluded.updated_at`, + cs.BookID, cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID, cs.ContentHash, + cs.Disposition, cs.FlagReason, cs.Attempts, cs.FinalHash, cs.CostUSD, cs.Detail) + return err +} + +// GetChunkStatus returns the disposition row for a chunk×stage, or nil if none. +func (s *Store) GetChunkStatus(bookID string, chapter, chunkIdx int, stage string) (*ChunkStatus, error) { + ctx, cancel := opContext() + defer cancel() + cs := ChunkStatus{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx, Stage: stage} + err := s.r.QueryRowContext(ctx, ` + SELECT snapshot_id, content_hash, disposition, flag_reason, attempts, final_hash, cost_usd, detail + FROM chunk_status + WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`, + bookID, chapter, chunkIdx, stage).Scan( + &cs.SnapshotID, &cs.ContentHash, &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cs, nil +} + +// ChunkStatusesForBook returns every disposition row for a book, ordered for a +// stable report (tmctl report: the flag section). +func (s *Store) ChunkStatusesForBook(bookID string) ([]ChunkStatus, error) { + ctx, cancel := opContext() + defer cancel() + rows, err := s.r.QueryContext(ctx, ` + SELECT chapter, chunk_idx, stage, snapshot_id, content_hash, disposition, flag_reason, + attempts, final_hash, cost_usd, detail + FROM chunk_status WHERE book_id = ? + ORDER BY chapter, chunk_idx, stage`, bookID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ChunkStatus + for rows.Next() { + cs := ChunkStatus{BookID: bookID} + if err := rows.Scan(&cs.Chapter, &cs.ChunkIdx, &cs.Stage, &cs.SnapshotID, &cs.ContentHash, + &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail); err != nil { + return nil, err + } + out = append(out, cs) + } + return out, rows.Err() +} diff --git a/backend/internal/store/chunkstatus_test.go b/backend/internal/store/chunkstatus_test.go new file mode 100644 index 0000000..50f264a --- /dev/null +++ b/backend/internal/store/chunkstatus_test.go @@ -0,0 +1,63 @@ +package store + +import "testing" + +func TestChunkStatusUpsertGetOverwrite(t *testing.T) { + s, _ := openTemp(t) + + if got, err := s.GetChunkStatus("book", 1, 0, "draft"); err != nil || got != nil { + t.Fatalf("empty store must return (nil,nil), got %+v err=%v", got, err) + } + + cs := ChunkStatus{ + BookID: "book", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "snap1", + Disposition: "flagged", FlagReason: "length", Attempts: 2, FinalHash: "", + CostUSD: 0.0034, Detail: "truncated at max_tokens", + } + if err := s.UpsertChunkStatus(cs); err != nil { + t.Fatal(err) + } + got, err := s.GetChunkStatus("book", 1, 0, "draft") + if err != nil { + t.Fatal(err) + } + if got == nil || got.Disposition != "flagged" || got.FlagReason != "length" || got.Attempts != 2 || got.CostUSD != 0.0034 { + t.Fatalf("round-trip mismatch: %+v", got) + } + + // Overwrite (the resolve semantics): a later verdict replaces the row. + cs.Disposition, cs.FlagReason, cs.FinalHash, cs.CostUSD = "ok", "", "hashABC", 0.0068 + if err := s.UpsertChunkStatus(cs); err != nil { + t.Fatal(err) + } + got, _ = s.GetChunkStatus("book", 1, 0, "draft") + if got.Disposition != "ok" || got.FlagReason != "" || got.FinalHash != "hashABC" || got.CostUSD != 0.0068 { + t.Fatalf("overwrite failed: %+v", got) + } +} + +func TestChunkStatusesForBookOrdered(t *testing.T) { + s, _ := openTemp(t) + rows := []ChunkStatus{ + {BookID: "b", Chapter: 2, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "ok"}, + {BookID: "b", Chapter: 1, ChunkIdx: 1, Stage: "edit", SnapshotID: "s", Disposition: "skipped"}, + {BookID: "b", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "flagged", FlagReason: "soft_refusal"}, + {BookID: "other", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "ok"}, + } + for _, r := range rows { + if err := s.UpsertChunkStatus(r); err != nil { + t.Fatal(err) + } + } + got, err := s.ChunkStatusesForBook("b") + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("want 3 rows for book b (other filtered), got %d", len(got)) + } + // Ordered by (chapter, chunk_idx, stage). + if got[0].Chapter != 1 || got[0].ChunkIdx != 0 || got[1].ChunkIdx != 1 || got[2].Chapter != 2 { + t.Fatalf("ordering wrong: %+v", got) + } +} diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go index f26a37e..d7acc6d 100644 --- a/backend/internal/store/migrate.go +++ b/backend/internal/store/migrate.go @@ -101,6 +101,31 @@ var migrations = []string{ ); CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts); `, + // v2: per-chunk×stage disposition (Веха 2, D2). Резолв ПОВЕРХ append-only + // checkpoints (keyed by request_hash-с-attempt) — не колонка на checkpoints: + // на resume строка пересобирается из чекпоинтов, поэтому её потеря на kill -9 + // самолечится (classify по тексту чекпоинта, без ре-биллинга). jobs.status + // контентом НЕ расширяется — failed остаётся инфра-сбоем; плохой чанк живёт + // здесь как disposition, а цикл продолжается. + ` + CREATE TABLE IF NOT EXISTS chunk_status ( + book_id TEXT NOT NULL, + chapter INTEGER NOT NULL, + chunk_idx INTEGER NOT NULL, + stage TEXT NOT NULL, + snapshot_id TEXT NOT NULL, -- под каким снапшотом резолвнут (stale после --resnapshot игнорируется) + content_hash TEXT NOT NULL DEFAULT '',-- сигнатура отрендеренных msgs (исходник НЕ в снапшоте): правка src делает позиционную строку недействительной, а не подаёт устаревший перевод + disposition TEXT NOT NULL, -- ok | flagged | skipped + flag_reason TEXT NOT NULL DEFAULT '', -- '' когда ok; Go-константа FlagReason + attempts INTEGER NOT NULL DEFAULT 0, + final_hash TEXT NOT NULL DEFAULT '', -- request_hash авторитетного чекпоинта (ok-путь подаёт его текст дальше) + cost_usd REAL NOT NULL DEFAULT 0, -- сумма по ВСЕМ попыткам (F3-честно: ретраи учтены) + detail TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (book_id, chapter, chunk_idx, stage) + ); + CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition); + `, } // migrate runs all pending migrations on the write pool, one transaction per diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a49c091..e106e98 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -257,6 +257,20 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор **Веха 2 (следующая):** runner chapter/chunk-loop + `chunk_status`/disposition (A, D2) — `flag_reason`-константы, `classify(text,finish)` (refusal/echo до length), ось attempt, exit-коды 0/2/1, фиксы F4 (усечённый length → flagged) + edit-`max_tokens` от длины черновика. +### 2026-07-04 — Сессия 4: Веха 2 Фазы 1 — chunk-loop + disposition (A, D2) + фиксы F4/D2.5 + +Снят хардкод «один чанк»: прогон идёт по всей книге, плохой кусок помечается и цикл продолжается. Всё зелёное: `go build/vet/test -race`, `tmctl report` ($0). Живые LLM не звались (моки + tmctl $0, как предписано). + +**Цикл по чанкам (шов под шаг 3).** `TranslateOneChunk`(chapter=1/chunk=0 хардкод) → `TranslateBook` → `translateChunk` (цикл стадий) → `runStage` → `runAttempt` (ось attempt). `RunResult`→`ChunkOutcome` (per-chunk) + `BookResult` (агрегат книги). Чанкер — **минимальный детерминированный плейсхолдер** (`chunker.go` `SplitChunks`: разбивка на главы по form-feed, паковка абзацев ≤1500 симв., абзац не режется); настоящий лингвистический чанкер — шаг 3, подменит `SplitChunks` не трогая цикл/disposition. `chunkerVersion` бампнут `v1-wholefile`→`v2-ff-para-pack` (в snapshot; ре-чанкинг = осознанный `--resnapshot`). + +**Схема disposition (D2, контракт-несущее).** v2-миграция (append, IF NOT EXISTS): `chunk_status(book_id,chapter,chunk_idx,stage,snapshot_id,content_hash,disposition,flag_reason,attempts,final_hash,cost_usd,detail)`, PK (book,chapter,chunk,stage). Это **резолв поверх append-only checkpoints**, не колонка на checkpoints — на resume пересобирается из чекпоинтов. `disposition ∈ {ok,flagged,skipped}`; `jobs.status` контентом НЕ расширён (failed = только инфра). `flag_reason` — Go-константы как `Finish*` (12 шт.; эмитятся length/empty/loop_degenerate/hard_refusal/soft_refusal/content_filter/cjk_artifact/decode_error, зарезервированы coverage_fail/excision_suspect/hard_block/upstream_not_ok под шаги 6–7). Единый `classify(src,out,finish,target)` — порядок: refusal-блэклист (порт `refusal_bench.py` 1:1, en/ru/zh/ja) / CJK-echo **ДО** length/empty (усечённый отказ не триггерит платную переатаку); `content_filter` best-effort (реальная страховка — блэклист). n-gram loop-чек ПЕРЕД удвоением max_tokens. `maxTokensForAttempt` — чистая функция (удвоение per attempt), её версия в snapshot (`maxTokensPolicy`). Retry-flagged только {length,empty} на той же модели по оси attempt (до `regenerate_before_escalate`, потом флаг); refusal/filter/cjk/loop/decode детерминированы → не переатакуются. Три анти-веджа отработали (**mutation-verified**): resume читает `chunk_status` до вызова; empty/decode → flagged (цикл идёт, не крэш); legacy-пустой чекпоинт самолечится classify-ем без ре-биллинга. Exit: `CompletedWithFlags` sentinel → 0 чисто / 2 с флагами (N допустимо) / 1 инфра. + +**Фиксы.** **F4:** усечённый `length` с непустым черновиком теперь классифицируется (flagged/retry), не течёт в edit как OK (`classify` после settle). **D2.5:** edit-`max_tokens` от длины ЧЕРНОВИКА (`prev`), не исходника — монолингвальный редактор работает по русскому черновику ≈1.9× исходника (mutation-verified). **F3 честно:** `chunk_status.cost_usd` суммирует все попытки; per-run cost отделён от кумулятивного; idempotency-ключ не делал (отложено). + +**Селфревью (адверсариальный воркфлоу, 5 измерений → верификация каждой находки).** disposition-money/resume/classifier — **0 дефектов**. 2 подтверждённых исправлено (оба mutation-verified): (1) *[детерминизм, medium]* `chunk_status` fast-path резолвил ПОЗИЦИОННО и подавал устаревший перевод при правке исходника (исходник не в snapshot) — фикс: сигнатура `content_hash` отрендеренных msgs в `chunk_status`, fast-path доверяет строке только при совпадении snapshot И content (иначе — content-safe attempt-loop; правка src = тихий per-chunk ре-перевод по §3.4, не устаревший подача); стоило переноса дешёвого рендера ДО чекпоинт-резолва (LLM-вызов по-прежнему загейчен — «не переатаковать» сохранено). (2) *[CLI, low]* `flag.ExitOnError` → `os.Exit(2)` на кривом флаге, коллизия с exit-2 «с флагами» → `ContinueOnError` (кривой флаг = exit 1). Тесты: 12 новых (disposition/chunker/chunkstatus юниты + e2e мульти-чанк/resume/flag+skip/exit-коды/F4/D2.5/decode/legacy-self-heal/source-edit), все mutation-проверены на укус. + +**Техдолг вперёд (не блокирует шаг 3):** чанкер — плейсхолдер (шаг 3 подменит `SplitChunks`, чтит контракт `Chunk` + бампает `chunkerVersion`); небилленный upstream-сбой = инфра-аболт (D4 pause/resume), `upstream_not_ok`/`hard_block`-константы под HTTP-классификацию шага 7; редкий угол: kill-9 в микро-окне (потеря `chunk_status`) ПЛЮС одновременное снижение `regenerate_before_escalate` может консервативно пере-флагнуть бывший-ok чанк (без денег/расхождения — фаст-путь чинит норму, self-heal best-effort). `jobs.status` теперь наблюдательный (ничего на нём не ветвится). + ## Полигон (секция параллельной сессии — записи добавлять сюда) @@ -299,7 +313,8 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор - [—] **Банк памяти (задача 5 + шире)** — по решению владельца выносится в **отдельную сессию «Валидация банка памяти»** (главная точка отказа пайплайна; не бросаться в код без разбора опасных сценариев). Полигон подготовил черновик промта → `docs/MEMORY_RESEARCH_SESSION_PROMPT.md`; **оркестратору вычитать/финализировать**, затем владелец запускает. Замер эмбеддингов (bge-m3 vs Qwen3-Embedding vs LaBSE) — часть мандата той сессии. - [ ] Довалидация токен-калибровки на 3–5 главах реальных вебновелл (файлы владельца). - [ ] Задача 4 (пилот выбора ядра, Фаза 2.5): протокол BWS + панель LLM-судей. -- [ ] **Эксперимент 05 — memory-bet (индикативный), готовая спека ниже** — решает go/no-go на ставку банка памяти; runnable СЕЙЧАС без бэкенда (чистый eval). +- [x] ~~**Эксперимент 05 — memory-bet**~~ — **СНЯТ как низкосигнальный** (решение владельца). Прогон был, но его ось C0-vs-C1 («бредит ли модель без глоссария») предрешена, а C3/C2 лишь пере-подтвердили уже процитированную литературу (2510.00829) — тест не мог изменить ни одного решения. Артефакты (doc/скрипт/данные) удалены. Единственный actionable-вывод (post-check + disposition обязательны) уже зафиксирован в реестре `architecture/06` (A2/E1) независимо от этого прогона. **Урок для eval:** тест ставить только если его исход мог бы перевернуть решение И не установлен литературой. Ценность вместо этого — валидация самого МЕХАНИЗМА (см. ниже). +- [x] **Референс горячего пути банка памяти + self-tests** → `eval/memory_hotpath.py` (11/11 PASS). Исполняемая спека механизма (не продукт): нормализация trad→simp/kana, точный матч ключей/алиасов с запретом одиночных (омографы 送灶/炎/修/气 НЕ инъектируются), спойлер-фильтр `since_ch/until_ch` (hard-reject), sticky для местоименных чанков, disposition confirmed/ambiguous/reject, post-check (ловит и утечку, и отравление). **Бэкенду:** порт в Go 1:1, тесты T1–T10 → unit-тесты; `[PROD]`-замены: OpenCC, Aho-Corasick, pymorphy/`decl`. > **[ЗАДАЧА ОРКЕСТРАТОРА → eval] Эксперимент 05: memory-bet (индикативный).** Отвечает на главный незакрытый вопрос валидации (research/13 §Вердикт): оправдан ли банк памяти vs просто длинный контекст, и вредит ли ошибочная инъекция (страх владельца). **Runnable сейчас, продукт не нужен** — это конструирование промптов + API-вызовы + метрики. > **Метод:** один стек `deepseek-chat` (черновик) → `grok-4.3` (редактор, thinking OFF, temp 0.4 — квирки в `00-provider-quirks`). Реальные zh→ru чанки: `eval/data/samples/zh/luxun-ah-q-ch1-4.txt` (повторяющиеся имена 阿Q/赵太爷/王胡/送灶; есть русский эталон Рогова `samples/ru/luxun-ah-q-ru.txt` для якоря верности). Нарезать ~5–8 перекрывающихся чанков; собрать мини-глоссарий (5–10 записей: имена+реалии, с dst и `decl`). **4 условия на чанк:** @@ -367,3 +382,7 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор > **[БЭКЕНДУ]** реестр `architecture/06` — твой контракт для шага 4 (миграция памяти v2). Порядок: **F1 (snapshotID под память+context-assembly) — ПЕРВЫМ** (общий гейт, держит D1/D5, независим от memory-вопроса — код отстаёт от impl-notes §3.2, там уже предписан версия-счётчик); затем схема v2 = D7 + добавления реестра (`sense`, ось редакционного времени, `UNIQUE(src,sense,window)`+обратная dst-проверка, `min_key_len`+запрет одиночных ключей, артефакт нормализации NFKC/OpenCC/kana/ruby); горячий путь = **Aho-Corasick/trie, НЕ FTS5** (impl-notes §3.6 уже так); post-check обязателен, слепой post-replace в ru запрещён; трёхсторонний disposition + retrieval-state запись. Режим инъекции держи конфигом (`selective|full_prefix`) — eval потом решит дефолт. Отложенное в v1.1/Ф2 (заложить поля/место, не реализовывать): Палладий/Поливанов B6, гейт фактичности резюме D1, эмбеддинг-слой A7, бэкап файла F4, морфо-гейт глагольного рода C3. > **[ПОЛИГОНУ/ПИЛОТУ]** in-house memory-eval — дециждающий эксперимент, фолдится в пилот Ф2.5: WMT25-трёхрежимный (no / correct / **random** terminology — random-рука ловит тихую деградацию) на реальных zh/ja→ru чанках; **обязательный baseline «банк ON vs OFF» и «банк vs длинный контекст frontier-модели»** на том же стеке (DelTA этого не делала — это пробел, который решает go/no-go на ставку); мерить две оси раздельно (самосогласованность approved-форм + source-fidelity/omission через CometKiwi + LLM-судья-против-источника); ru-специфика (согласование рода вкл. глагол, склонение dst); предзарегистрировать пороги + допустимый flag-volume на главу. Эмбеддинг-бенчмарк уже сделан этой сессией (bge-m3 дефолт, `eval/retrieval_bench.py`) — переиспользуй. + +> **[СЕССИЯ ЗАКРЫТА, 04.07]** Выходы: research/13 (+§«Честные слабые места»), architecture/06, `eval/memory_hotpath.py` (спека), `eval/retrieval_bench.py` (эмбеддинги). Эксп-05 снят как низкосигнальный. **Перед кодом реализующей сессии — прочитать research/13 §«Честные слабые места»:** F1 подан как решённый, но там развилка (snapshot материализует байты vs store версионирует глоссарий); post-check в русской морфологии — несущий, но невалидированный (померить до доверия как гейту); «пусто=безопасно» — размен на ложный-пропуск, который тоже тихий; ставка на детерминированный no-LLM путь — cost-driven, не evidence-driven (DelTA валидирует LLM-в-цикле). Гигиена: фоновых процессов нет, ollama полигона не тронут, `eval/.venv` дополнен (torch-cpu/sentence-transformers/openai/dotenv — переиспользуемо). + +> **[ДОБАВЛЕНО после закрытия] Локалка × банк памяти — тест экстракции (дыра G1)** → `eval/extract_bench.py`. Локалка касается банка в 2 точках: эмбеддинги (сделано, bge-m3) и экстракция терминов (write-path). Индикативно (3 кейса zh+ja): **локаль хорошо СПОТИТ сущности** (qwen3-abliterated:8b recall 1.0, 0 галлюцинаций, ~15с) но **плохо РЕНДЕРИТ dst** (阿Q→«А Цзы», 羅生門→«Россомаха») → **разделить: спот локально (дёшево) / рендер dst — облако+человек** (уточняет Р4/G1, где «экстракция=локаль» слито в одно). Побочно: deepseek-v4-flash молча вернул ПУСТОЙ ответ на части чанков (祝福 воспроизводимо) — тот empty-paid-response, ради которого существуют D2/coverage-гейт. Аггрегат deepseek по recall не репортил (контаминирован пустыми) — только per-case. Оговорка: 3 кейса, индикативно.