137 lines
7.4 KiB
Go
137 lines
7.4 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/llm"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// escalation.go: single-hop эскалация детерминированного контент-провала (D12/D3) —
|
||
// один хоп на другую модель под budget_usd, результат РЕ-гейтится, retry-бюджет не
|
||
// сбрасывается. Канал B (18+) Ф2 расширяет ИМЕННО этот файл (permissive-цепочки).
|
||
|
||
// applyModelFloor raises a derived max_tokens budget to the CALL model's per-model
|
||
// floor (D24.3, capabilities.min_max_tokens): a thinking model returns finish=length
|
||
// or empty content below a per-model minimum because reasoning consumes the budget
|
||
// before content is emitted. The floor is taken by the model of THIS call — a primary
|
||
// attempt floors against st.Model, an escalation hop against st.EscalateTo — so a hop
|
||
// that inherits a smaller primary budget re-derives its own (exactly what broke the
|
||
// draft→deepseek-v4-pro hops on acceptance stage A: they inherited 2048/3291 and
|
||
// returned length). 0 floor (reasoning-off models) leaves the budget untouched.
|
||
// Applied BEFORE the request_hash so it is wire-visible and snapshot-folded.
|
||
//
|
||
// ⚠ LATENT INTERACTION (D28.3б, note not a defect — no floored local model exists today): a
|
||
// `provider_local` kind overrides max_tokens on the wire AFTER the request-hash (prov.MaxTokens,
|
||
// snapshot.go ProviderMaxTok fold), so for a FLOORED local model the provider override would win
|
||
// over this floor on the actual wire. Both are in the snapshot (floor via Capability.MinMaxTokens,
|
||
// override via ProviderMaxTok), so it is a loud --resnapshot either way — but if a local model ever
|
||
// gets a min_max_tokens floor, decide explicitly whether the provider max_tokens override or the
|
||
// floor takes precedence (today the local provider max_tokens=8192 already covers thinking budgets).
|
||
func (r *Runner) applyModelFloor(base int, model string) int {
|
||
if floor := r.Models.MinMaxTokens(model); floor > base {
|
||
return floor
|
||
}
|
||
return base
|
||
}
|
||
|
||
// errReserveCeiling is wrapped into the error runAttempt returns when a book/day USD
|
||
// ceiling denies a reservation. The PRIMARY path propagates it (the book durably
|
||
// pauses and resumes once the ceiling is raised, D4); the OPTIONAL escalation hop
|
||
// catches it and degrades to keeping the primary flag instead of aborting the whole
|
||
// book on every run (self-review finding).
|
||
var errReserveCeiling = errors.New("reserve ceiling reached")
|
||
|
||
// escalationBudgetRemains reports whether the book may still spend on a single-hop
|
||
// fallback draft: escalation is OPT-IN via escalation.budget_usd (0 = disabled, the
|
||
// boevoy default — a stage's escalate_to is inert until a premium budget is set), and
|
||
// capped at that budget summed over the book's escalation checkpoints (money-path
|
||
// durable, resume-safe). A PRE-HOP soft cap: the gate admits a hop while spent <
|
||
// budget and does NOT pre-estimate the hop's own cost, so a single hop may overshoot
|
||
// the budget by up to its full cost; the NEXT chunk's escalation is then denied. Size
|
||
// the budget with that worst case in mind — it bounds TOTAL escalation, not per-hop.
|
||
func (r *Runner) escalationBudgetRemains() (bool, error) {
|
||
budget := r.Pipeline.Escal.BudgetUSD
|
||
if budget <= 0 {
|
||
return false, nil
|
||
}
|
||
spent, err := r.Store.EscalationSpentUSD(r.Book.BookID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return spent < budget, nil
|
||
}
|
||
|
||
// escalationOutcome is maybeEscalate's report to runStage. attempted is true ONLY
|
||
// when a hop actually executed (fresh or replayed from its checkpoint) — a chunk
|
||
// whose hop was skipped (not escalatable / no escalate_to / budget exhausted) or
|
||
// denied by a USD ceiling reports attempted=false, so runStage keeps the primary
|
||
// flag and records Escalated=false (the exact pre-extraction semantics).
|
||
type escalationOutcome struct {
|
||
attempted bool
|
||
fb stageAttempt // the fallback attempt; meaningful only when attempted
|
||
}
|
||
|
||
// maybeEscalate runs the single-hop escalation (D12 deterministic-content-failure
|
||
// class): a flag that a DIFFERENT model might fix (echo / excision / refusal) is
|
||
// routed ONCE to the stage's named fallback, under the book's escalation.budget_usd,
|
||
// and RE-GATED (classifyOutput runs on the fallback output too — §3.8). The editor
|
||
// declares no escalate_to → pinned per book (its style must not drift to a foreign
|
||
// model, D12/2605.13368). The fallback uses its OWN model (the request_hash axis), so
|
||
// its checkpoint never collides with the failed primary's, and it is exactly ONE call
|
||
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never
|
||
// reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is
|
||
// the attempt loop's terminal (flagged) attempt.
|
||
func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt, isFinal bool) (escalationOutcome, error) {
|
||
var out escalationOutcome
|
||
if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
|
||
return out, nil
|
||
}
|
||
// The hop budget is the primary's base re-floored against the HOP's own model
|
||
// (D24.3): the fallback may be a thinking model with a larger minimum than the
|
||
// primary (draft deepseek-v4-flash → hop deepseek-v4-pro both floor 8000; a
|
||
// floor-less grok primary → a floored hop lifts to the hop's minimum). This is
|
||
// exactly the stage-A fix — the hop no longer inherits a sub-floor 2048/3291.
|
||
hopMaxTokens := r.applyModelFloor(baseMaxTokens, st.EscalateTo)
|
||
// Idempotency (self-review): if the fallback hop already happened its
|
||
// checkpoint exists and was already paid — REPLAY it for free regardless of the
|
||
// budget, so a crash AFTER the hop settled but BEFORE chunk_status was written
|
||
// re-serves it on resume rather than discarding a paid, successful translation
|
||
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The
|
||
// hash mirrors runAttempt's for (model=EscalateTo, attempt=0, maxTokens=hopMaxTokens).
|
||
fbHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, 0, st.Name, st.Role, st.EscalateTo,
|
||
st.Temperature, st.Reasoning, false, hopMaxTokens, snapID, msgs)
|
||
fbExists, err := r.Store.GetCheckpoint(fbHash)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
mayHop := fbExists != nil
|
||
if !mayHop {
|
||
if mayHop, err = r.escalationBudgetRemains(); err != nil {
|
||
return out, err
|
||
}
|
||
}
|
||
if !mayHop {
|
||
return out, nil
|
||
}
|
||
fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal)
|
||
if err != nil {
|
||
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
|
||
// (and re-abort on every resume): the chunk is already flagged, so keep
|
||
// the primary flag and continue. Any other infra error still aborts.
|
||
if !errors.Is(err, errReserveCeiling) {
|
||
return out, err
|
||
}
|
||
r.Log.WarnContext(ctx, "escalation hop denied by a USD ceiling; keeping the primary flag",
|
||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "reason", string(primary.cls.Reason))
|
||
return out, nil
|
||
}
|
||
out.attempted, out.fb = true, fb
|
||
r.Log.WarnContext(ctx, "stage escalated to a fallback model",
|
||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
|
||
"primary_reason", string(primary.cls.Reason), "fallback", st.EscalateTo,
|
||
"fallback_disposition", string(fb.cls.Reason.disposition()))
|
||
return out, nil
|
||
}
|