router: introduce typed ingress boundary and route producer observability
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.
This commit is contained in:
@@ -68,6 +68,16 @@ type Record struct {
|
||||
Winner string `json:"winner"`
|
||||
Claims []Claim `json:"claims"`
|
||||
|
||||
// InputSource — which channel the utterance arrived on (tap:voice or
|
||||
// tap:text). Carried for observability so a trace can distinguish a voice
|
||||
// turn from a text turn without re-deriving it from surrounding claims.
|
||||
InputSource string `json:"input_source,omitempty"`
|
||||
|
||||
// RouteProducer — which cascade stage produced the routing decision.
|
||||
// Carried for observability so a trace names the winning component directly
|
||||
// rather than requiring a scan of the claims list.
|
||||
RouteProducer string `json:"route_producer,omitempty"`
|
||||
|
||||
mu sync.Mutex
|
||||
rosters []roster
|
||||
}
|
||||
@@ -152,9 +162,10 @@ func (r *Record) Finish(now time.Time) *Record {
|
||||
type recorderKey struct{}
|
||||
|
||||
// With returns a context carrying a fresh record, and the record to read after
|
||||
// the turn has answered.
|
||||
func With(ctx context.Context, utterance string) (context.Context, *Record) {
|
||||
rec := &Record{Utterance: utterance}
|
||||
// the turn has answered. inputSource identifies the channel the utterance
|
||||
// arrived on; pass empty if unknown.
|
||||
func With(ctx context.Context, utterance string, inputSource string) (context.Context, *Record) {
|
||||
rec := &Record{Utterance: utterance, InputSource: inputSource}
|
||||
return context.WithValue(ctx, recorderKey{}, rec), rec
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ package router
|
||||
|
||||
import "time"
|
||||
|
||||
// RouteProducer — which stage of the cascade produced the routing decision.
|
||||
// Recorded for observability so a trace can name the winning component without
|
||||
// re-deriving it from the stage number and surrounding claims.
|
||||
type RouteProducer string
|
||||
|
||||
const (
|
||||
RouteProducerGrammar RouteProducer = "grammar"
|
||||
RouteProducerHeads RouteProducer = "heads"
|
||||
RouteProducerLLM RouteProducer = "llm"
|
||||
RouteProducerClassifier RouteProducer = "classifier"
|
||||
)
|
||||
|
||||
// Intent — the seven save-where labels from docs/design.md's routing table. The
|
||||
// discriminator is "does the loop evaluate a predicate against it?":
|
||||
//
|
||||
@@ -105,6 +117,10 @@ type Decision struct {
|
||||
Slots Slots
|
||||
Clarify bool // stage 3: below threshold — ask, don't guess
|
||||
|
||||
// Producer — which cascade stage produced this decision. Recorded for
|
||||
// observability so a trace can name the winning component directly.
|
||||
Producer RouteProducer
|
||||
|
||||
// Source — where the answer lives, for a query. The second half of the
|
||||
// route, and empty on every other intent. SourceUnknown means no decider
|
||||
// named one and the daemon walks its whole chain, which is what shipped
|
||||
|
||||
@@ -97,6 +97,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
continue // grammar matched shape but not content → fall through
|
||||
}
|
||||
d.Utterance = utterance
|
||||
d.Producer = RouteProducerGrammar
|
||||
// A literal pattern named that destination, which is the one provenance
|
||||
// allowed to take the personal boundary off a turn (V-666). Set here and
|
||||
// nowhere else, so no other arm of the cascade can claim it.
|
||||
@@ -138,6 +139,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
Confidence: res.Confidence,
|
||||
Source: res.Source,
|
||||
Clarify: res.Clarify,
|
||||
Producer: RouteProducerHeads,
|
||||
}
|
||||
r.fillSlots(ctx, &d, now)
|
||||
// The clarify head relearned the English assumption that one word
|
||||
@@ -180,6 +182,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
if r.llm != nil {
|
||||
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
|
||||
d.Utterance = utterance
|
||||
d.Producer = RouteProducerLLM
|
||||
r.fillSlots(ctx, &d, now)
|
||||
before := d.Confidence
|
||||
r.gateLLMDecision(&d)
|
||||
@@ -239,6 +242,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
Intent: best.Intent,
|
||||
Confidence: best.Score,
|
||||
Slots: r.extractor.Extract(ctx, best.Intent, utterance, now),
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
|
||||
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
|
||||
|
||||
@@ -76,3 +76,26 @@ func ValidSource(s Source) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InputSource — which channel this utterance arrived on. The same provenance
|
||||
// vocabulary facts use (internal/event). Threaded through the turn because a
|
||||
// turn can write a fact, and a fact that lies about where it came from is
|
||||
// worse than no fact: provenance is the first column read when asking why a
|
||||
// daemon-wide setting is the way it is.
|
||||
type InputSource string
|
||||
|
||||
const (
|
||||
// InputSourceVoice — a real microphone (PushToTalk).
|
||||
InputSourceVoice InputSource = "tap:voice"
|
||||
// InputSourceText — mavweb /api/chat, telegram, or any text entry point.
|
||||
InputSourceText InputSource = "tap:text"
|
||||
)
|
||||
|
||||
// NormalizedInput — the typed ingress boundary for a turn. Text is the raw
|
||||
// utterance after STT (voice) or as typed (text). Source identifies the
|
||||
// channel. This slice performs no new linguistic normalization: text and voice
|
||||
// paths continue to converge onto the same turn path as they did before.
|
||||
type NormalizedInput struct {
|
||||
Text string
|
||||
Source InputSource
|
||||
}
|
||||
|
||||
@@ -385,6 +385,11 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_entries_live_candidate
|
||||
ON digest_entries (rule, candidate_fingerprint)
|
||||
WHERE status = 'pending' AND candidate_fingerprint <> '';`,
|
||||
|
||||
// #27 — route_producer on routing_traces. Records which cascade stage
|
||||
// (grammar, heads, llm, classifier) produced the routing decision, so a
|
||||
// trace can name the winning component directly.
|
||||
`ALTER TABLE routing_traces ADD COLUMN route_producer TEXT NOT NULL DEFAULT '';`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -30,6 +30,9 @@ type RoutingTrace struct {
|
||||
Source string `json:"source"`
|
||||
Winner string `json:"winner"`
|
||||
Intent string `json:"intent"`
|
||||
// RouteProducer — which cascade stage produced the routing decision.
|
||||
// grammar, heads, llm, or classifier. Empty on pre-route turns.
|
||||
RouteProducer string `json:"route_producer,omitempty"`
|
||||
// ClaimedBeforeHead — stage 0 or a pre-route resolver answered, so the turn
|
||||
// teaches nothing about the classifier. It is a large share of real traffic,
|
||||
// and counting those turns as training signal would fit the head to the
|
||||
@@ -56,10 +59,10 @@ func (s *Store) WriteRoutingTrace(ctx context.Context, tr RoutingTrace) (int64,
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO routing_traces
|
||||
(ts, utterance, source, winner, intent, claimed_before_head, encoder_id, outcome, correction, claims)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
(ts, utterance, source, winner, intent, route_producer, claimed_before_head, encoder_id, outcome, correction, claims)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
tr.Ts.UnixMilli(), tr.Utterance, tr.Source, tr.Winner, tr.Intent,
|
||||
tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
|
||||
tr.RouteProducer, tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("write routing trace: %w", err)
|
||||
}
|
||||
@@ -91,8 +94,8 @@ func (s *Store) PruneRoutingTraces(ctx context.Context, before time.Time) error
|
||||
// RecentRoutingTraces returns the newest n turns, newest first.
|
||||
func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, utterance, source, winner, intent, claimed_before_head,
|
||||
encoder_id, outcome, correction, claims
|
||||
SELECT id, ts, utterance, source, winner, intent, route_producer,
|
||||
claimed_before_head, encoder_id, outcome, correction, claims
|
||||
FROM routing_traces
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, n)
|
||||
@@ -106,8 +109,8 @@ func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace,
|
||||
var tsMilli int64
|
||||
var claims string
|
||||
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Utterance, &tr.Source, &tr.Winner,
|
||||
&tr.Intent, &tr.ClaimedBeforeHead, &tr.EncoderID, &tr.Outcome,
|
||||
&tr.Correction, &claims); err != nil {
|
||||
&tr.Intent, &tr.RouteProducer, &tr.ClaimedBeforeHead, &tr.EncoderID,
|
||||
&tr.Outcome, &tr.Correction, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr.Ts = time.UnixMilli(tsMilli).UTC()
|
||||
|
||||
Reference in New Issue
Block a user