Harden Phase 0 after self-review: snapshot coverage, response-price fallback, usage clamp, job-status lifecycle, config guards, source normalization, hash robustness
This commit is contained in:
parent
2006989b9b
commit
214e005155
17 changed files with 490 additions and 32 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
71
backend/internal/config/config_test.go
Normal file
71
backend/internal/config/config_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 симв.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// символа на токен. Оценка — для резервов, не для биллинга.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, гейты (пороги полигона готовы), режим онгоинга.
|
||||
|
||||
## Полигон
|
||||
(секция параллельной сессии — записи добавлять сюда)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 потребуют мьютекса/предзагрузки.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue