Add chapter/chunk book loop with per-chunk disposition flags, attempt-axis retries, content-hashed resume, and F4/D2.5 fixes
This commit is contained in:
parent
0dbcce7e97
commit
357dde296b
12 changed files with 1965 additions and 309 deletions
|
|
@ -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
|
||||
|
|
|
|||
88
backend/internal/pipeline/chunker.go
Normal file
88
backend/internal/pipeline/chunker.go
Normal file
|
|
@ -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
|
||||
}
|
||||
96
backend/internal/pipeline/chunker_test.go
Normal file
96
backend/internal/pipeline/chunker_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
286
backend/internal/pipeline/disposition.go
Normal file
286
backend/internal/pipeline/disposition.go
Normal file
|
|
@ -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 <think>…</think> 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)<think>.*?</think>\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)
|
||||
}
|
||||
138
backend/internal/pipeline/disposition_test.go
Normal file
138
backend/internal/pipeline/disposition_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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. Без этого файл,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
backend/internal/store/chunkstatus.go
Normal file
106
backend/internal/store/chunkstatus.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
63
backend/internal/store/chunkstatus_test.go
Normal file
63
backend/internal/store/chunkstatus_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 кейса, индикативно.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue