From 214e0051551930e6e00c59f21b6fe8dd50e74ea3 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 4 Jul 2026 14:49:43 +0300 Subject: [PATCH] Harden Phase 0 after self-review: snapshot coverage, response-price fallback, usage clamp, job-status lifecycle, config guards, source normalization, hash robustness --- backend/go.mod | 1 + backend/go.sum | 6 +- backend/internal/config/book.go | 7 +- backend/internal/config/config_test.go | 71 ++++++++++++++++++++ backend/internal/config/models.go | 38 +++++++++++ backend/internal/config/pipeline.go | 23 +++++++ backend/internal/ledger/pricing.go | 40 +++++++++-- backend/internal/ledger/pricing_test.go | 31 +++++++++ backend/internal/llm/httpllm.go | 18 +++-- backend/internal/llm/provider_anthropic.go | 12 +++- backend/internal/pipeline/render.go | 36 ++++++++-- backend/internal/pipeline/render_test.go | 39 +++++++++++ backend/internal/pipeline/runner.go | 65 +++++++++++++++--- backend/internal/pipeline/runner_test.go | 55 +++++++++++++++ backend/internal/store/store.go | 7 +- docs/PROGRESS.md | 36 ++++++++++ docs/architecture/03-implementation-notes.md | 37 ++++++++++ 17 files changed, 490 insertions(+), 32 deletions(-) create mode 100644 backend/internal/config/config_test.go diff --git a/backend/go.mod b/backend/go.mod index e1c9827..ad13b34 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module textmachine/backend go 1.26.4 require ( + golang.org/x/text v0.38.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 ) diff --git a/backend/go.sum b/backend/go.sum index 8d8dee1..782939f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -14,11 +14,13 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/backend/internal/config/book.go b/backend/internal/config/book.go index a48ba3a..0e1b62e 100644 --- a/backend/internal/config/book.go +++ b/backend/internal/config/book.go @@ -113,8 +113,13 @@ func LoadBook(path string) (*Book, error) { // проекта в другую директорию не должен пере-переводить книгу. JSON с // фиксированным порядком полей → детерминированный байтовый рендер. func (b *Book) BriefHash() string { + // Title входит в бриф: это смысловое поле (используется промптами через + // {{title}} и попадает в рендер → request-hash), не wiring-идентификатор. + // Без него правка заголовка молча инвалидировала бы чекпоинты в обход + // snapshot-гейта (находка ревью). canon := struct { BookID string `json:"book_id"` + Title string `json:"title"` SourceLang string `json:"source_lang"` TargetLang string `json:"target_lang"` Genre string `json:"genre"` @@ -124,7 +129,7 @@ func (b *Book) BriefHash() string { Honorifics string `json:"honorifics"` Transcription string `json:"transcription"` Footnotes string `json:"footnotes"` - }{b.BookID, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes} + }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes} data, err := json.Marshal(canon) if err != nil { // A struct of scalars cannot fail to marshal; keep the signature clean. diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..d5c4019 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "strings" + "testing" +) + +func TestCheckRunnableRejectsPhase2Mechanics(t *testing.T) { + cases := []struct { + name string + p Pipeline + want string // substring the error must mention + }{ + {"c1 ok", Pipeline{Core: "C1", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""}, + {"c0 ok", Pipeline{Core: "C0", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""}, + {"c2 rejected", Pipeline{Core: "C2", Stages: []Stage{{Name: "draft", Role: "translator"}}}, "C2"}, + {"fanout rejected", Pipeline{Core: "C1", Fanout: Fanout{Candidates: 3}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, "fanout"}, + {"judge rejected", Pipeline{Core: "C1", Stages: []Stage{{Name: "select", Role: "judge"}}}, "judge"}, + } + for _, c := range cases { + err := c.p.CheckRunnable() + if c.want == "" { + if err != nil { + t.Errorf("%s: expected runnable, got %v", c.name, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: expected error mentioning %q, got %v", c.name, c.want, err) + } + } +} + +func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) { + m := &Models{ + Providers: map[string]Provider{ + "cloud": {Kind: "openai", BaseURL: "http://x", APIKeyEnv: "TEST_CLOUD_KEY"}, + "local": {Kind: "local", BaseURL: "http://127.0.0.1", Model: "q"}, + }, + Models: map[string]Model{ + "cloud-model": {Provider: "cloud"}, + "local-model": {Provider: "local"}, + "unused-cloud": {Provider: "cloud"}, + }, + } + // local-only pipeline не должен падать на облачном ключе. + localPipe := &Pipeline{Stages: []Stage{{Model: "local-model"}}} + if err := m.CheckKeys(localPipe); err != nil { + t.Fatalf("local-only pipeline must not require cloud keys: %v", err) + } + // Пайплайн с облачной моделью без ключа — fail-fast. + t.Setenv("TEST_CLOUD_KEY", "") + cloudPipe := &Pipeline{Stages: []Stage{{Model: "cloud-model"}}} + if err := m.CheckKeys(cloudPipe); err == nil || !strings.Contains(err.Error(), "TEST_CLOUD_KEY") { + t.Fatalf("missing cloud key must fail-fast mentioning the env var, got %v", err) + } + // Ключ задан — проходит. + t.Setenv("TEST_CLOUD_KEY", "sk-xxx") + if err := m.CheckKeys(cloudPipe); err != nil { + t.Fatalf("present key must pass: %v", err) + } + // Escalation-цепочки тоже учитываются. + escPipe := &Pipeline{ + Stages: []Stage{{Model: "local-model"}}, + Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}}, + } + t.Setenv("TEST_CLOUD_KEY", "") + if err := m.CheckKeys(escPipe); err == nil { + t.Fatal("escalation chain model without a key must fail-fast") + } +} diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 455f7b4..77b96f3 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -184,3 +184,41 @@ func (p Provider) APIKey() string { } return os.Getenv(p.APIKeyEnv) } + +// CheckKeys verifies that every non-local model REFERENCED by the pipeline has +// its provider API key resolvable in the environment — preflight fail-fast, +// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота +// (находка ревью). Проверяются только используемые модели: local-only прогон +// не должен падать на незаполненных облачных ключах. Escalation-цепочки тоже +// учитываются — их модели вызываются в Фазе 1. +func (m *Models) CheckKeys(pipe *Pipeline) error { + needed := map[string]struct{}{} + for _, st := range pipe.Stages { + needed[st.Model] = struct{}{} + } + for _, chain := range pipe.Escal.Chains { + for _, mdl := range chain { + needed[mdl] = struct{}{} + } + } + var problems []string + seen := map[string]bool{} // dedupe per provider + for name := range needed { + mod, ok := m.Models[name] + if !ok { + continue // существование уже проверено LoadPipeline + } + prov := m.Providers[mod.Provider] + if prov.Kind == "local" || prov.APIKeyEnv == "" || seen[mod.Provider] { + continue + } + seen[mod.Provider] = true + if prov.APIKey() == "" { + problems = append(problems, fmt.Sprintf("provider %s: env %s не задан (нужен для модели %s)", mod.Provider, prov.APIKeyEnv, name)) + } + } + if len(problems) > 0 { + return fmt.Errorf("отсутствуют API-ключи (заполните backend/.env):\n - %s", strings.Join(problems, "\n - ")) + } + return nil +} diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index 87b013b..8b87c9f 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -93,6 +93,29 @@ type Fanout struct { Candidates int `yaml:"candidates"` } +// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT +// implement — раздельно от schema-валидации (LoadPipeline), чтобы скелеты C2/C3 +// парсились и валидировались как схема, но НЕ исполнялись молча. Без этого +// гейта запуск pipeline-c2.yaml прогнал бы стадии линейно: fanout.candidates +// игнорируется, вердикт судьи утёк бы в редактуру, а деньги Opus сгорели бы на +// мусор (находка ревью; Р2 требует закрывать дыру «конфиг vs код» fail-loud). +func (p *Pipeline) CheckRunnable() error { + switch p.Core { + case "C0", "C1": + default: + return fmt.Errorf("pipeline core %q не исполним в Фазе 0 (реализованы только C0/C1 — линейный проход стадий; C2/C3 selection/fusion — механика раннера Фазы 2)", p.Core) + } + if p.Fanout.Candidates > 1 { + return fmt.Errorf("pipeline fanout.candidates=%d не исполним в Фазе 0 (fan-out N кандидатов — механика раннера Фазы 2; C1 = один черновик)", p.Fanout.Candidates) + } + for _, st := range p.Stages { + if st.Role == "judge" { + return fmt.Errorf("pipeline stage %q role=judge не исполним в Фазе 0 (селектор получает N кандидатов — механика Фазы 2, а раннер гонит стадии линейно)", st.Name) + } + } + return nil +} + // LoadPipeline reads and validates a pipeline config against the models known // to models.yaml. func LoadPipeline(path string, models *Models) (*Pipeline, error) { diff --git a/backend/internal/ledger/pricing.go b/backend/internal/ledger/pricing.go index 5e15dca..749d2c9 100644 --- a/backend/internal/ledger/pricing.go +++ b/backend/internal/ledger/pricing.go @@ -52,6 +52,22 @@ func (p *Pricer) PriceFor(model string) ModelPrice { return p.defaultPrice } +// PriceForResponse prices a completion by the model that ACTUALLY answered, +// but if that id is unknown (провайдер вернул канонизированный/датированный +// слаг вроде claude-sonnet-5-2026xxxx) falls back to the REQUESTED model's +// price BEFORE the global anchor — иначе премиум-ответ книжился бы по дешёвому +// дефолту (систематический недоучёт, находка ревью). Только если неизвестны +// обе — дефолтный якорь (never $0). +func (p *Pricer) PriceForResponse(requested, actual string) ModelPrice { + if mp, ok := p.prices[actual]; ok { + return mp + } + if mp, ok := p.prices[requested]; ok { + return mp + } + return p.defaultPrice +} + // CostUSD prices one completion by its usage. Formula (invariant from // llm.Usage: PromptTokens = total input = uncached + cached + cache-written): // @@ -61,17 +77,29 @@ func (p *Pricer) PriceFor(model string) ModelPrice { // For providers without cache-write accounting the third term is 0 and the // formula degrades to vojo's computeUSD. func CostUSD(price ModelPrice, u llm.Usage) float64 { - uncached := u.PromptTokens - u.CachedTokens - u.CacheCreationTokens + // Каждое слагаемое зажимается в ≥0: провайдер с багом учёта, приславший + // отрицательные токены, иначе УМЕНЬШИЛ бы committed и ослабил потолок + // (находка ревью). Стоимость может только расти или быть нулевой. + nn := func(x int) int { + if x < 0 { + return 0 + } + return x + } + cached := nn(u.CachedTokens) + cacheWrite := nn(u.CacheCreationTokens) + completion := nn(u.CompletionTokens) + nn(u.ReasoningTokens) + uncached := nn(u.PromptTokens) - cached - cacheWrite if uncached < 0 { - // Defensive: a provider reporting cached > prompt would otherwise - // produce a negative term and understate cost. + // cached+write > prompt (провайдер отрапортовал подмножество больше + // целого) — не уводим некэш-слагаемое в минус. uncached = 0 } perTok := func(perM float64) float64 { return perM / 1_000_000 } return float64(uncached)*perTok(price.InputPerM) + - float64(u.CachedTokens)*perTok(price.CachedPerM) + - float64(u.CacheCreationTokens)*perTok(price.CacheWritePerM) + - float64(u.CompletionTokens+u.ReasoningTokens)*perTok(price.OutputPerM) + float64(cached)*perTok(price.CachedPerM) + + float64(cacheWrite)*perTok(price.CacheWritePerM) + + float64(completion)*perTok(price.OutputPerM) } // EstimateUSD is the pre-call reservation estimate: assume the whole prompt diff --git a/backend/internal/ledger/pricing_test.go b/backend/internal/ledger/pricing_test.go index b92635d..f7f9dcd 100644 --- a/backend/internal/ledger/pricing_test.go +++ b/backend/internal/ledger/pricing_test.go @@ -38,6 +38,37 @@ func TestCostUSD(t *testing.T) { u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10} want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6 approx(t, CostUSD(price, u4), want4, "cached > prompt guard") + + // Отрицательные usage-числа (баг учёта провайдера) не должны УМЕНЬШАТЬ + // стоимость — каждое слагаемое зажимается в ≥0. + u5 := llm.Usage{PromptTokens: -1000, CachedTokens: -50, CompletionTokens: -200, ReasoningTokens: -5} + if got := CostUSD(price, u5); got != 0 { + t.Fatalf("negative usage must never reduce cost below 0, got %v", got) + } +} + +func TestPriceForResponse(t *testing.T) { + def := ModelPrice{InputPerM: 0.14, OutputPerM: 0.28} // дешёвый якорь + p, err := NewPricer(map[string]ModelPrice{ + "deepseek-v4-flash": def, + "claude-sonnet-5": {InputPerM: 2.0, OutputPerM: 10.0}, + }, def) + if err != nil { + t.Fatal(err) + } + // Фактически ответившая известна — по ней. + if p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5").InputPerM != 2.0 { + t.Fatal("known actual model must price by actual") + } + // Провайдер вернул датированный слаг премиум-модели — НЕ дешёвый якорь, а + // цена ЗАПРОШЕННОЙ модели (иначе систематический недоучёт премиума). + if got := p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5-20260101").InputPerM; got != 2.0 { + t.Fatalf("unknown actual must fall back to requested model price (2.0), got %v", got) + } + // Обе неизвестны — дефолтный якорь (never $0). + if got := p.PriceForResponse("mystery", "also-mystery").InputPerM; got != 0.14 { + t.Fatalf("both unknown must fall back to default anchor, got %v", got) + } } func TestPricerNeverZero(t *testing.T) { diff --git a/backend/internal/llm/httpllm.go b/backend/internal/llm/httpllm.go index 46a8033..738549d 100644 --- a/backend/internal/llm/httpllm.go +++ b/backend/internal/llm/httpllm.go @@ -297,7 +297,12 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp return nil, ctx.Err() == nil, err } defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + // Читаем на 1 байт больше лимита, чтобы ОТЛИЧИТЬ усечение от целого тела. + data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + truncated := len(data) > maxResponseBytes + if truncated { + data = data[:maxResponseBytes] + } obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data) @@ -310,10 +315,13 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp var out openAIResponse if err := json.Unmarshal(data, &out); err != nil { - // 2xx с нечитаемым телом (обрыв/прокси/обрезка LimitReader): провайдер - // УЖЕ списал деньги. Retryable (повтор может прийти целым); типизировано, - // чтобы раннер на исчерпании попыток НЕ вернул резерв, а консервативно - // заселтлил оценку — недоучёт хуже переучёта для потолков. + // 2xx с нечитаемым телом: провайдер УЖЕ списал деньги. Типизируем, чтобы + // раннер консервативно заселтлил оценку. Усечённое (>16 MiB) тело + // детерминированно — ретраить бессмысленно (каждый ретрай — новый + // оплаченный 2xx), поэтому терминально; обрыв/мусор — retryable. + if truncated { + return nil, false, &BilledDecodeError{Provider: c.name, Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)} + } return nil, true, &BilledDecodeError{Provider: c.name, Err: err} } // A 2xx is a billed call even when the model returns empty content diff --git a/backend/internal/llm/provider_anthropic.go b/backend/internal/llm/provider_anthropic.go index 650f0eb..43c10d7 100644 --- a/backend/internal/llm/provider_anthropic.go +++ b/backend/internal/llm/provider_anthropic.go @@ -203,7 +203,11 @@ func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResp return nil, ctx.Err() == nil, err } defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + truncated := len(data) > maxResponseBytes + if truncated { + data = data[:maxResponseBytes] + } obs.LogLLMExchange(ctx, c.log, "anthropic", payload, resp.StatusCode, data) @@ -217,7 +221,11 @@ func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResp var out anthropicResponse if err := json.Unmarshal(data, &out); err != nil { - // 2xx уже оплачен провайдером — типизируем, retryable (см. httpllm.go). + // 2xx уже оплачен провайдером — типизируем (см. httpllm.go); усечение + // терминально, обрыв/мусор — retryable. + if truncated { + return nil, false, &BilledDecodeError{Provider: "anthropic", Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)} + } return nil, true, &BilledDecodeError{Provider: "anthropic", Err: err} } diff --git a/backend/internal/pipeline/render.go b/backend/internal/pipeline/render.go index a596c3a..8d77fc2 100644 --- a/backend/internal/pipeline/render.go +++ b/backend/internal/pipeline/render.go @@ -6,6 +6,7 @@ package pipeline import ( "crypto/sha256" + "encoding/binary" "encoding/hex" "fmt" "os" @@ -13,6 +14,8 @@ import ( "strings" "unicode" + "golang.org/x/text/unicode/norm" + "textmachine/backend/internal/config" "textmachine/backend/internal/llm" ) @@ -25,10 +28,11 @@ import ( // resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix // match) промахивается. -// chunkerVersion versions the segmentation rules; it is part of the snapshot, -// so re-chunking a book is an explicit re-translation, not a silent cache miss +// 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-v0-wholefile" +const chunkerVersion = "chunker-v1-wholefile-nfc" // estimatorVersion versions EstimateTokens: его выход входит в max_tokens и // через него в request-hash, поэтому перекалибровка весов — тоже явная @@ -153,21 +157,43 @@ func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) { // только что провалившийся чекпоинт и мгновенно эскалировала в премиум). func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string { h := sha256.New() + // Каждое поле длина-префиксовано (8 байт LE) вместо NUL-разделителя: + // контент с байтом \x00 больше не может сдвинуть границы полей или + // подделать лишние сообщения (находка ревью). Версия v2 фиксирует смену + // формата хеша. + var lb [8]byte w := func(parts ...string) { for _, p := range parts { + binary.LittleEndian.PutUint64(lb[:], uint64(len(p))) + h.Write(lb[:]) h.Write([]byte(p)) - h.Write([]byte{0}) } } - w("tm-request-v1", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model, + w("tm-request-v2", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model, strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly), strconv.Itoa(maxTokens), snapshotID) + // Явный счётчик сообщений: длина списка — тоже часть ключа. + binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs))) + h.Write(lb[:]) for _, m := range msgs { w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary)) } 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. Без этого файл, +// сохранённый в другом редакторе (BOM) или на Windows (CRLF), давал бы иной +// hash и молча пере-переводил бы уже оплаченную книгу. +func NormalizeSource(s string) string { + s = strings.TrimPrefix(s, "\uFEFF") // UTF-8 BOM + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = norm.NFC.String(s) + return strings.TrimSpace(s) +} + // EstimateTokens is a cheap, deterministic token estimate for reservation // sizing and max_tokens derivation (NOT for billing — billing uses the API's // usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв. diff --git a/backend/internal/pipeline/render_test.go b/backend/internal/pipeline/render_test.go index 919e863..1223c25 100644 --- a/backend/internal/pipeline/render_test.go +++ b/backend/internal/pipeline/render_test.go @@ -130,6 +130,45 @@ func TestTemplateRequiresUserSeparator(t *testing.T) { } } +func TestNormalizeSource(t *testing.T) { + // BOM, CRLF/CR и разложенная форма Unicode → канон; хеш стабилен между + // ОС/редакторами. + crlf := "\uFEFFпервая\r\nвторая\rтретья" + if got := NormalizeSource(crlf); got != "первая\nвторая\nтретья" { + t.Fatalf("BOM/CRLF/CR not normalized: %q", got) + } + // Явно строим NFD (e + U+0301) и NFC (U+00E9), чтобы формы точно различались. + decomposed := "cafe\u0301" + precomposed := "caf\u00e9" + if decomposed == precomposed { + t.Fatal("test setup: the two forms must differ before normalization") + } + if NormalizeSource(decomposed) != NormalizeSource(precomposed) { + t.Fatal("NFD and NFC forms must normalize to the same string") + } + // Разные представления одного текста → один request-hash. + tpl := writeTemplate(t, "S.\n---USER---\n{{text}}") + m1, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(decomposed)}) + m2, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(precomposed)}) + h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1) + h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m2) + if h1 != h2 { + t.Fatal("normalized equivalent sources must yield the same request hash") + } +} + +// Length-prefix устраняет NUL-коллизию: контент с \x00 не сдвигает границы +// полей и не подделывает лишние сообщения. +func TestRequestHashNulRobust(t *testing.T) { + a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", + []llm.Message{{Role: "user", Content: "a\x00b"}}) + bb := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", + []llm.Message{{Role: "user", Content: "a"}, {Role: "\x00b", Content: ""}}) + if a == bb { + t.Fatal("NUL byte in content must not collide with an extra message boundary") + } +} + func TestEstimateTokens(t *testing.T) { // Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3 // символа на токен. Оценка — для резервов, не для биллинга. diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index befa92c..d39c40f 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -58,6 +58,14 @@ func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) { if err != nil { return nil, err } + // Fail-fast ДО открытия store (взятия flock и side-effect'ов): исполнима + // ли механика конфига и заданы ли ключи используемых моделей. + if err := pipe.CheckRunnable(); err != nil { + return nil, err + } + if err := models.CheckKeys(pipe); err != nil { + return nil, err + } pricer, err := models.Prices() if err != nil { return nil, err @@ -121,25 +129,45 @@ func (r *Runner) snapshotID() (id, payload string, err error) { PromptSHA256 string `json:"prompt_sha256"` Temperature float64 `json:"temperature"` Reasoning string `json:"reasoning"` + // ExtraBody модели меняет ТЕЛО запроса (GLM thinking-off и т.п.) — + // без него правка ручки в models.yaml молча не инвалидировала бы + // чекпоинты (находка ревью). json.Marshal карты сортирует ключи → + // детерминированный рендер. + ModelExtra json.RawMessage `json:"model_extra,omitempty"` } snap := struct { BriefHash string `json:"brief_hash"` ChunkerVersion string `json:"chunker_version"` EstimatorVersion string `json:"estimator_version"` PipelineCore string `json:"pipeline_core"` - Stages []stageSnap `json:"stages"` + // Defaults влияют на maxTokens, а тот входит в request-hash: без них + // правка max_output_ratio молча инвалидировала бы все чекпоинты в + // обход snapshot-гейта (находка ревью). + MaxOutputRatio float64 `json:"max_output_ratio"` + MinMaxTokens int `json:"min_max_tokens"` + Stages []stageSnap `json:"stages"` }{ BriefHash: r.Book.BriefHash(), ChunkerVersion: chunkerVersion, EstimatorVersion: estimatorVersion, PipelineCore: r.Pipeline.Core, + MaxOutputRatio: r.Pipeline.Defaults.MaxOutputRatio, + MinMaxTokens: r.Pipeline.Defaults.MinMaxTokens, } for _, st := range r.Pipeline.Stages { - snap.Stages = append(snap.Stages, stageSnap{ + ss := stageSnap{ Name: st.Name, Role: st.Role, Model: st.Model, PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256, Temperature: st.Temperature, Reasoning: st.Reasoning, - }) + } + if extra := r.Models.Models[st.Model].ExtraBody; len(extra) > 0 { + raw, merr := json.Marshal(extra) + if merr != nil { + return "", "", merr + } + ss.ModelExtra = raw + } + snap.Stages = append(snap.Stages, ss) } data, err := json.Marshal(snap) if err != nil { @@ -180,7 +208,8 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) { if err != nil { return nil, fmt.Errorf("pipeline: read source: %w", err) } - source := strings.TrimSpace(string(srcRaw)) + // Нормализуем (BOM/CRLF/NFC) — request-hash стабилен между ОС/редакторами. + source := NormalizeSource(string(srcRaw)) if source == "" { return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile) } @@ -228,9 +257,10 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c } r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12]) } - if err := r.Store.SetJobStatus(job.ID, "running"); err != nil { - return nil, err - } + // Статус 'running' выставляется НЕ здесь, а прямо перед вызовом провайдера: + // иначе отказ потолка/ошибка сборки клиента/промах гейта оставляли бы + // джобу навсегда в 'running' (находка ревью). Отказ потолка — retriable, + // джоба остаётся 'pending'; настоящая ошибка — 'failed'. msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev}) if err != nil { @@ -293,10 +323,12 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD, }) if err != nil { + _ = r.Store.SetJobStatus(job.ID, "failed") return nil, 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) case store.ReserveDeniedDay: return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD) @@ -305,9 +337,14 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c client, err := r.client(st.Model) if err != nil { _ = r.Store.Release(resv) + _ = r.Store.SetJobStatus(job.ID, "failed") return nil, err } + if err := r.Store.SetJobStatus(job.ID, "running"); err != nil { + _ = r.Store.Release(resv) + return nil, err + } start := time.Now() resp, err := client.Complete(ctx, llm.LLMRequest{ Model: st.Model, @@ -357,7 +394,19 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c if modelActual == "" { modelActual = st.Model } - cost := ledger.CostUSD(r.Pricer.PriceFor(modelActual), 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. + 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)) + cost = estimate + } usageJSON, err := json.Marshal(resp.Usage) if err != nil { return nil, err diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go index c081fe3..d855591 100644 --- a/backend/internal/pipeline/runner_test.go +++ b/backend/internal/pipeline/runner_test.go @@ -225,6 +225,61 @@ func TestRunnerSnapshotPinning(t *testing.T) { } } +// Платный 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"}], + "usage":{"prompt_tokens":0,"completion_tokens":0}}`) + })) + defer srv.Close() + bookPath := setupProject(t, srv.URL) + + r, err := NewRunner(bookPath, obs.NewLogger()) + if err != nil { + t.Fatal(err) + } + defer r.Close() + res, err := r.TranslateOneChunk(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) + } + 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) + 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 { + t.Fatal(err) + } + writeFile(t, pipePath, string(raw)+"\nfanout: { candidates: 3 }\n") + + 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()) + } +} + func TestRunnerCeilingDenies(t *testing.T) { var calls atomic.Int32 srv := newFakeProvider(t, &calls) diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 74a3f00..7e896da 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -139,9 +139,10 @@ func (s *Store) recoverReservations(ctx context.Context) error { return fmt.Errorf("store: recover reservations: %w", err) } if n, _ := res.RowsAffected(); n > 0 { - // The caller's logger isn't wired here; surface via the request_log-less - // path is overkill — a plain stderr note keeps it visible. - fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n) + // stderr, НЕ stdout: stdout — это полезный вывод перевода/отчёта, и + // эта строка не должна засорять перенаправленный результат (находка + // ревью). Логгер сюда не прокинут (Open — до его построения). + fmt.Fprintf(os.Stderr, "store: recovered %d stale reservation row(s) from a previous run\n", n) } return nil } diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c8d4f0d..f42a124 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -73,6 +73,42 @@ > 5. **Р5 обновлён** сноской на токен-калибровку (×1.4–1.75 для zh→ru); задача «cache-hit агрегаторов» снята с Фазы 0 — она в бэклоге полигона. > **Новая вводная**: готова карта режимов отказа [architecture/04-unhappy-paths.md](architecture/04-unhappy-paths.md) (~70 режимов → механизмы). Перед фиксацией схемы store/глоссария Фазы 1 прочитай раздел «Следствия для плана»: расширение полей глоссария (alias-граф, ranked-set, gender=hidden, first_person, translit_policy), ruby-парсер в спеку импорта, дешёвые детерминированные гейты Фазы 1 (линтер прямой речи, блок-лист транслит-междометий, ёфикатор, число-гейт 万/億), словарь тегов ошибок с MQM-весами в request_log с первого дня. Пороги coverage-гейта уже сняты полигоном (см. его секцию, эксперимент 02). +### 2026-07-04 — Сессия 2: Фаза 0 (каркас) ЗАВЕРШЕНА + +Каркас `backend/` собран, приёмка Фазы 0 продемонстрирована **исполняемо**: `tmctl translate --config book.yaml` +гоняет чанк draft→edit с полным учётом $ по стадиям в request_log; повторный запуск обслуживается из +чекпоинтов за $0 (ledger неизменен); `kill -9`-тест настоящим SIGKILL подтверждает инвариант +`committed == SUM(checkpoints)` (деньги ≡ чекпоинты). `go test ./...`, `go vet`, `-race` — зелёные. + +- **Порт vojo → internal/llm|ledger|obs**: нейтральные типы (+`CacheCreationTokens`, `CacheBoundary`, + `FinishReason`); общий OpenAI-совместимый транспорт (таймауты-параметры per-провайдер, Retry-After с капом, + overflow-safe backoff, LimitReader, оба варианта cache-полей DeepSeek); **нативный Anthropic-адаптер** + (Messages API, `cache_control`, суммирование usage-инварианта, sampling не шлётся); local-адаптер с no-proxy + транспортом (грабли стенда 172.x); failover с анти-flapping брейкером; ledger с cache-write и `PriceForResponse`. +- **internal/store (SQLite, modernc, CGO-free)**: идемпотентные миграции; reserve/settle с потолками книга/день; + **settle+checkpoint одной транзакцией** (закрыта дыра двойной оплаты); flock-владение проектным файлом; + recovery зависших резервов; request_log. +- **internal/config (fail-fast)**: models.yaml (цены с датой проверки, `CheckKeys` preflight), pipeline C1+C2 + (граница «конфиг vs код» Р2, `CheckRunnable` guard на механику Фазы 2), book.yaml + brief_hash. +- **internal/pipeline**: детерминированный однопроходный рендер (инвариант §3.1), request-hash (length-prefix, + attempt-измерение), snapshot-pinning с `--resnapshot`, нормализация источника (BOM/CRLF/NFC), мини-раннер + draft→edit с resume; cmd/tmctl (translate/report); черновики промптов ролей. +- **Финал — агентное селфревью**: `/code-review` (8 линз) + воркфлоу-критики (4 линзы задания) с адверсариальной + верификацией; подтверждённые находки исправлены двумя волнами (детали и остаточный техдолг — + [03-implementation-notes §6](architecture/03-implementation-notes.md)). Коммиты: `dc6ea8e` каркас → + `a32ec2e` волна 1 → волна 2. + +Учтено из ответа оркестратора: ключ TM `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, +context_snapshot_hash)` — совпадает с составом моего snapshot-payload (prompt_version+model+PromptSHA256 per-role, +brief_hash, chunker/estimator version); перечанкование инвалидирует by design (chunkerVersion в snapshot). +Карту 04-unhappy-paths заберу в схему store/глоссария при старте Фазы 1. + +**Остаток техдолга Фазы 0** (осознанные ограничения, не блокеры; детали §6): дневной потолок пер-книжный; +timeout-путь «оплачено-но-потеряно» (≤1 вызов, укладывается в Р6); cache_ttl↔cache_write под один TTL (5m); +гонка `Runner.clients` (безопасна при последовательном проходе, мьютекс — к fan-out Фазы 1–2). + +Следующее: Фаза 1 (перевод книги целиком) — чанкер, банк памяти v1, гейты (пороги полигона готовы), режим онгоинга. + ## Полигон (секция параллельной сессии — записи добавлять сюда) diff --git a/docs/architecture/03-implementation-notes.md b/docs/architecture/03-implementation-notes.md index 444e6c0..52350c0 100644 --- a/docs/architecture/03-implementation-notes.md +++ b/docs/architecture/03-implementation-notes.md @@ -192,3 +192,40 @@ C2, cache TTL per-стадия. В models.yaml: цены (+cache write/read), п полигону (у него харнесс сравнения usage), бэкенд потребит результат в конфиге; TMX в диаграммах не помечен «(Фаза 3)»; морфодетектор/CometKiwi/NSFW-классификатор в диаграммах без фазовых пометок; режим онгоинга отсутствует в диаграммах (и не определено, перегоняется ли Analyst при дозагрузке глав). + +## 6. Селфревью Фазы 0 (агентное, 2026-07-04) + +Финал Фазы 0: `/code-review` (8 finder-линз + верификация) + воркфлоу-критики (4 линзы задания: архитектура/ +соответствие v2, корректность и деньги, идиоматичность Go, устойчивость к сбоям) с адверсариальной проверкой +каждой находки. Подтверждённые исправлены **двумя волнами** (коммиты `dc6ea8e` → `a32ec2e` → фиксы v2): + +**Волна 1 (коммит a32ec2e):** общий retry-движок (устранена копипаста Anthropic↔OpenAI, кап Retry-After +`maxRetryAfterWait` 5м, overflow-safe backoff); `BilledDecodeError` — 2xx с нечитаемым телом консервативно +селтлит оценку, не возвращает резерв; пустой оплаченный ответ — громкая остановка, не отравляет редактуру; +snapshot-pinning гейт с `--resnapshot`; flock-владение проектным файлом; цены локальных тегов $0; однопроходный +рендер; `temperature` без omitempty (Anthropic вообще не шлёт sampling — 400); `attempt` в ключе и схеме. + +**Волна 2 (эта):** snapshot-покрытие расширено (Defaults ratio/min, Title→BriefHash, модельный ExtraBody — +правки конфига больше не инвалидируют чекпоинты в обход гейта); `PriceForResponse` — неизвестный слаг ответа +книжится по ЗАПРОШЕННОЙ модели, не по дешёвому якорю (устранён недоучёт премиума); usage-кламп в `CostUSD` +(отрицательные числа не уменьшают committed); zero-usage платный 2xx → settle оценки + WARN; жизненный цикл +статуса джобы (не остаётся `running` на отказе потолка/ошибке клиента); `CheckRunnable` — fail-fast на +нереализованную механику (C2/C3, fan-out>1, роль judge — иначе `pipeline-c2.yaml` прогнал бы стадии линейно и +сжёг деньги Opus на мусор); `CheckKeys` — preflight API-ключей used-моделей (пустой ключ ловится до reserve, не +401-м после); request-hash — length-prefix полей + счётчик сообщений (устранена NUL-коллизия); нормализация +источника BOM/CRLF/NFC (детерминизм хеша между ОС/редакторами); LimitReader-усечение терминально (не ретраит +оплаченный >16 MiB); recoverReservations → stderr. + +**Остаточный техдолг (осознанные ограничения Фазы 0, не блокеры):** +- **Дневной потолок — пер-книжный** (ledger в файле проекта одной книги): `day_usd` сбрасывается в UTC-полночь + и ограничивает burn-rate одной книги, но не «на оператора за день по всем проектам». Общий потолок оператора + потребует shared spend-файла — отложено (раскладка «файл БД на книгу» из Р3/Р6); пометить в UI при мультикниге. +- **Timeout-путь «оплачено, но потеряно»**: если провайдер начал биллить 2xx, а соединение оборвалось по + таймауту до получения тела, вызов повторяется — недоучёт на один вызов. Это неустранимо для любой системы с + retry-on-timeout и укладывается в честные «≤1 вызов» (гарантия Р6 — про kill -9, не про billing на брошенном + соединении). `BilledDecodeError` закрывает случай, когда тело ЧАСТИЧНО дошло; чистый таймаут до тела — нет. +- **cache_ttl ↔ cache_write_per_m**: цена записи в кэш в models.yaml задаётся под ОДИН TTL (у нас 5m). Если + оператор поставит `cache_ttl: 1h`, реальная запись стоит ×2 (не ×1.25) — недоучёт. Боевой конфиг 5m совпадает; + при переходе на 1h требуется отдельная цена. Валидацию/вторую цену — Фаза 1 (когда включим 1h осознанно). +- **Гонка `Runner.clients` map**: ленивая инициализация клиентов не синхронизирована — безопасна в Фазе 0 + (последовательный проход стадий), но параллельные чанки/fan-out Фазы 1–2 потребуют мьютекса/предзагрузки.