70 lines
3.3 KiB
Go
70 lines
3.3 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/llm"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// resume.go: обслуживание уже РЕШЁННОГО чанк×стадии из chunk_status/чекпоинтов —
|
||
// $0, без вызова провайдера; ok-стадия отдаёт авторитетный текст следующей стадии,
|
||
// терминальный флаг никогда не переатакуется (анти-ведж №1).
|
||
|
||
// 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,
|
||
// Escalation attribution now travels on chunk_status (D15.3), so it is preserved
|
||
// across resume for BOTH an ok escalation AND a flagged-then-escalated chunk (the
|
||
// status read-model reads it without re-opening the checkpoint).
|
||
Escalated: cs.Escalated, EscalationModel: cs.EscalationModel,
|
||
}
|
||
if cs.Disposition == string(DispOK) {
|
||
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pipeline: read final checkpoint %.12s for %s/ch%d/chunk%d/%s: %w",
|
||
cs.FinalHash, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, 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
|
||
if uerr := json.Unmarshal([]byte(cp.UsageJSON), &usage); uerr != nil {
|
||
r.Log.DebugContext(ctx, "checkpoint usage_json unreadable; tokens report as zero", "hash", cs.FinalHash[:12], "err", uerr)
|
||
}
|
||
sr.Usage = usage
|
||
sr.Model = cp.ModelActual
|
||
sr.FinishReason = cp.FinishReason
|
||
sr.Text = cp.ResponseText
|
||
}
|
||
rl := r.baseRequestLog(st, ch, st.Model, cs.FinalHash)
|
||
rl.ModelActual = sr.Model
|
||
rl.TMHit, rl.OK = true, cs.Disposition == string(DispOK)
|
||
rl.FinishReason, rl.Degraded = sr.FinishReason, nonOKTag(cs.Disposition, cs.FlagReason)
|
||
r.Store.LogRequest(ctx, r.Log, rl)
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|