87a3b163e7
First behavior-preserving slice of the Maven redesign. Establishes explicit ingress/routing boundaries and enough observability to refactor later without changing current routing, action, clarification, or execution semantics. Types introduced: - NormalizedInput (internal/router/source.go): Text + InputSource, the typed ingress boundary replacing raw string at the turn entry. - InputSource (internal/router/source.go): channel provenance enum (tap:voice, tap:text). Reuses the existing turnSource distinction. - RouteProducer (internal/router/intent.go): which cascade stage produced the decision (grammar, heads, llm, classifier). Changes: - Decision carries a Producer RouteProducer field, set at each cascade stage (grammar, heads, LLM, classifier). - turnRoute carries NormalizedInput instead of bare text string. - runTurn takes NormalizedInput instead of (text, src). - decision.Record carries InputSource and RouteProducer for observability; RoutingTrace persists route_producer (migration #27). - turnSource is now a type alias for router.InputSource. Behavior preserved: - Stage-0 grammars unchanged: same order, same matching, same confidence. - Cascade fallthrough order unchanged (grammar → heads → llm → classifier). - Clarification behavior unchanged. - Action dispatch unchanged. - No new linguistic normalization.
180 lines
6.6 KiB
Go
180 lines
6.6 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),
|
|
RouteProducer: rec.RouteProducer,
|
|
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)
|
|
// And the spoken path, which has no reply to hang a badge on: a correction
|
|
// said out loud points at the previous turn, so it needs that turn's row
|
|
// (V-636, repair.go).
|
|
h.stampLastTurn(rec.Utterance, 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
|
|
}
|