4d97280d74
The correction is the only supervised signal in the box, so the cost of giving one has to be near zero. That means the surface needs the trace id of the turn it is showing, which it had no way to learn: handleText returns one string and the trace was written after the reply left. The id rides back on ChatReply through the same context sink querySource uses, so the mic, telegram and the web keep the one signature they share. CorrectTurn takes a trace id and an optional target, which is deliberately reach-agnostic: nothing about it assumes a browser. store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is gone, and that is the expected outcome of correcting an old turn, not a broken database.
175 lines
6.3 KiB
Go
175 lines
6.3 KiB
Go
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
|
|
//
|
|
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
|
|
// argument that a turn record is read minutes later or never. The owner reversed
|
|
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
|
|
// calibrated without real utterances and there is no other source of them. The
|
|
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
|
|
//
|
|
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
|
|
// wired no store still gets. This file is the second sink beside it, and it is
|
|
// nil unless the daemon has a database — no store, no trace, no error.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/decision"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// traceWriter is the seam the handler persists through. store.Store satisfies
|
|
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
|
|
type traceWriter interface {
|
|
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
|
|
}
|
|
|
|
// traceSink wraps the store, or returns nil when there is none. A typed nil
|
|
// pointer assigned straight into the interface would be non-nil and would panic
|
|
// on the first turn, which is the classic shape of this bug.
|
|
func traceSink(s *store.Store) traceWriter {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// The trace id rides the context, the same seam querysource.go uses and for the
|
|
// same reason: handleText answers every reach through one string, and threading
|
|
// a second value through the whole action dispatch would change a signature the
|
|
// mic, telegram and the web all share. A caller that wants the id asks for a
|
|
// sink; the mic path does not, and pays nothing.
|
|
type traceIDKey struct{}
|
|
|
|
type traceIDSink struct {
|
|
mu sync.Mutex
|
|
id int64
|
|
}
|
|
|
|
func (s *traceIDSink) note(id int64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.id = id
|
|
}
|
|
|
|
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
|
|
func (s *traceIDSink) ID() int64 {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.id
|
|
}
|
|
|
|
// withTraceIDSink returns a context that collects the persisted trace id, and
|
|
// the sink to read after the turn has answered.
|
|
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
|
|
sink := &traceIDSink{}
|
|
return context.WithValue(ctx, traceIDKey{}, sink), sink
|
|
}
|
|
|
|
func noteTraceID(ctx context.Context, id int64) {
|
|
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
|
|
sink.note(id)
|
|
}
|
|
}
|
|
|
|
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
|
|
// alone is not enough: a box that goes quiet for a month keeps every row until
|
|
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
|
|
// only of a box in daily use. Called for its effect and never blocks a start.
|
|
func pruneTracesOnStart(s *store.Store, now time.Time) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
|
|
log.Printf("routing trace: prune on start: %v", err)
|
|
}
|
|
}
|
|
|
|
// persistDecision writes one finished record. It takes the same *decision.Record
|
|
// the ring takes, so the two sinks cannot disagree about what the turn did.
|
|
//
|
|
// Errors are logged and swallowed. A trace is diagnostic and training data, and
|
|
// a failed insert must never change what the owner hears.
|
|
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
|
|
ctx := turnCtx
|
|
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
|
|
return
|
|
}
|
|
// Detached from the turn's context, and bounded on its own. Two reasons, and
|
|
// the first is the one that matters: the turn is over by the time this runs,
|
|
// so a caller that hung up or timed out would cancel the insert, and the turn
|
|
// he abandoned halfway is exactly the one worth having. The second is that a
|
|
// write must not hold the reply, so it gets a second and no more.
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
|
|
defer cancel()
|
|
claims, err := json.Marshal(rec.Claims)
|
|
if err != nil {
|
|
log.Printf("routing trace: marshal claims: %v", err)
|
|
return
|
|
}
|
|
tr := store.RoutingTrace{
|
|
Ts: rec.Ts,
|
|
Utterance: rec.Utterance,
|
|
Source: string(src),
|
|
Winner: rec.Winner,
|
|
Intent: wonIntent(rec),
|
|
ClaimedBeforeHead: claimedBeforeHead(rec),
|
|
EncoderID: h.encoderID,
|
|
Outcome: wonAt(rec, decision.StageAction),
|
|
Claims: claims,
|
|
}
|
|
id, err := h.traces.WriteRoutingTrace(ctx, tr)
|
|
if err != nil {
|
|
log.Printf("routing trace: write: %v", err)
|
|
return
|
|
}
|
|
// The id goes back to whoever asked for it, so /chat can offer a correction
|
|
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
|
|
// not the detached one above: the sink belongs to the caller's turn.
|
|
noteTraceID(turnCtx, id)
|
|
}
|
|
|
|
// wonIntent — what the winning claimant made the turn. Read from the claim
|
|
// rather than from the route, because a pre-route resolver wins without routing
|
|
// and its intent is the honest answer to "what was this turn".
|
|
func wonIntent(rec *decision.Record) string {
|
|
for _, c := range rec.Claims {
|
|
if c.Outcome == decision.Won && c.Intent != "" {
|
|
return c.Intent
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// wonAt — the claimant that won at one stage. The action stage is what actually
|
|
// produced the reply, which is a different question from what was routed: a
|
|
// route that reached a gap and a route that ran are not the same turn.
|
|
func wonAt(rec *decision.Record, stage string) string {
|
|
for _, c := range rec.Claims {
|
|
if c.Stage == stage && c.Outcome == decision.Won {
|
|
return c.Claimant
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
|
|
// turn teaches nothing about the classifier. Those are a large share of real
|
|
// traffic, and fitting a head on them would fit it to the grammars rather than
|
|
// to him. Recorded per turn rather than filtered on write, because which share
|
|
// that is happens to be the number V-632 needs to know.
|
|
func claimedBeforeHead(rec *decision.Record) bool {
|
|
stage, _, ok := strings.Cut(rec.Winner, ":")
|
|
if !ok {
|
|
return false
|
|
}
|
|
return stage == decision.StagePreRoute || stage == decision.StageZero
|
|
}
|