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:
2026-09-05 20:16:40 +04:00
parent 2f338a1ab6
commit 87a3b163e7
15 changed files with 109 additions and 42 deletions
+1 -1
View File
@@ -439,7 +439,7 @@ func TestClarifySecondGapExhaustionResumesLowerFlow(t *testing.T) {
h.clarifyStore.Push(voiceDialogueID, older)
h.clarifyStore.Push(voiceDialogueID, top)
reply := h.runTurn(ctx, "купить хлеб", sourceText)
reply := h.runTurn(ctx, router.NormalizedInput{Text: "купить хлеб", Source: sourceText})
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
want := withResumed(clarifyGaveUp, resumed)
if reply != want {
+1 -1
View File
@@ -126,7 +126,7 @@ func TestRunTurnExplicitNoteStoresOnlyTheBody(t *testing.T) {
}
const utterance = "запомни: запасной ключ лежит в синей коробке"
if reply := h.runTurn(ctx, utterance, sourceText); reply != "сохранила заметку." {
if reply := h.runTurn(ctx, router.NormalizedInput{Text: utterance, Source: sourceText}); reply != "сохранила заметку." {
t.Fatalf("reply = %q, want the fixed feminine acknowledgement", reply)
}
if model.calls != 0 {
+1 -1
View File
@@ -382,7 +382,7 @@ func TestReminderCancellationIsAPreRouteTurnAndDoesNotGetSwallowedByClarify(t *t
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
})
reply := h.runTurn(ctx, "отмени напоминание про врача", sourceText)
reply := h.runTurn(ctx, router.NormalizedInput{Text: "отмени напоминание про врача", Source: sourceText})
if !strings.Contains(reply, clarifyDropped) || !strings.Contains(reply, "отменила напоминание") {
t.Fatalf("reply = %q, want dropped clarify notice and cancellation", reply)
}
+3 -3
View File
@@ -149,7 +149,7 @@ func TestRepairResumesQuestionParkedAfterTheCorrectedTurn(t *testing.T) {
t.Fatal("expected a parked reminder question")
}
reply := h.runTurn(ctx, "нет, это был вопрос", sourceText)
reply := h.runTurn(ctx, router.NormalizedInput{Text: "нет, это был вопрос", Source: sourceText})
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("the correction hid the still-live question: reply=%q want suffix=%q", reply, resumed)
@@ -176,14 +176,14 @@ func TestRepairedClarifyCompletesWithoutDroppingTheOlderQuestion(t *testing.T) {
t.Fatal("expected the older reminder question")
}
if reply := h.runTurn(ctx, "нет, это было напоминание", sourceText); !strings.Contains(reply, "Когда") {
if reply := h.runTurn(ctx, router.NormalizedInput{Text: "нет, это было напоминание", Source: sourceText}); !strings.Contains(reply, "Когда") {
t.Fatalf("the repaired reminder did not ask for its missing time: %q", reply)
}
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 2 {
t.Fatalf("the repaired question overwrote the older one: depth=%d want=2", depth)
}
reply := h.runTurn(ctx, "сегодня в 15:00", sourceText)
reply := h.runTurn(ctx, router.NormalizedInput{Text: "сегодня в 15:00", Source: sourceText})
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("completing the repaired request did not resume the older one: reply=%q", reply)
+1
View File
@@ -120,6 +120,7 @@ func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision
Source: string(src),
Winner: rec.Winner,
Intent: wonIntent(rec),
RouteProducer: rec.RouteProducer,
ClaimedBeforeHead: claimedBeforeHead(rec),
EncoderID: h.encoderID,
Outcome: wonAt(rec, decision.StageAction),
+1 -1
View File
@@ -696,7 +696,7 @@ func (w *simWorld) stimulate(ctx context.Context, s step) {
switch {
case s.Say != "":
reply := w.handler.runTurn(ctx, s.Say, sourceText)
reply := w.handler.runTurn(ctx, router.NormalizedInput{Text: s.Say, Source: sourceText})
w.replies = append(w.replies, reply)
w.logf("он: %s", s.Say)
w.logf("она: %s", reply)
+2 -2
View File
@@ -264,10 +264,10 @@ func TestClarifyCancelEndsTheExchange(t *testing.T) {
// the same memo.
func TestTheTurnIsRoutedOnce(t *testing.T) {
h, _ := newRoutingClarifyHandler(t)
rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now())
rt := h.newTurnRoute(router.NormalizedInput{Text: "какая сейчас погода в Риме?", Source: sourceText}, h.now())
ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt)
first, ok := h.routeForRole(ctx, rt.text)
first, ok := h.routeForRole(ctx, rt.input.Text)
if !ok {
t.Fatal("the cascade must produce a decision to classify against")
}
+8 -8
View File
@@ -18,9 +18,9 @@ import (
// second on the resident model and — worse — could disagree with itself, which
// is exactly the class of bug this task is about.
type turnRoute struct {
h *reactiveHandler
text string
now time.Time
h *reactiveHandler
input router.NormalizedInput
now time.Time
once sync.Once
dec router.Decision
@@ -46,8 +46,8 @@ type turnRoute struct {
type turnRouteKey struct{}
func (h *reactiveHandler) newTurnRoute(text string, now time.Time) *turnRoute {
return &turnRoute{h: h, text: text, now: now}
func (h *reactiveHandler) newTurnRoute(input router.NormalizedInput, now time.Time) *turnRoute {
return &turnRoute{h: h, input: input, now: now}
}
func withTurnRoute(ctx context.Context, rt *turnRoute) context.Context {
@@ -70,7 +70,7 @@ func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialog
if r.h.dialogueSessions != nil {
r.prev = r.h.dialogueSessions.Get(dialogueIDOf(ctx), r.now)
}
if dec, cont := continuationDecision(r.prev, r.text, r.now); cont {
if dec, cont := continuationDecision(r.prev, r.input.Text, r.now); cont {
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
r.dec, r.cont = dec, true
return
@@ -79,7 +79,7 @@ func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialog
r.err = router.ErrNoIntents
return
}
r.dec, r.err = r.h.router.Route(ctx, r.text, r.now)
r.dec, r.err = r.h.router.Route(ctx, r.input.Text, r.now)
})
return r.dec, r.cont, r.prev, r.err
}
@@ -92,7 +92,7 @@ func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialog
func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router.Decision, bool) {
rt := turnRouteFrom(ctx)
if rt == nil {
rt = h.newTurnRoute(text, h.now())
rt = h.newTurnRoute(router.NormalizedInput{Text: text, Source: sourceText}, h.now())
}
dec, _, _, err := rt.resolve(ctx)
if err != nil {
+19 -15
View File
@@ -211,7 +211,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
// action → replier), identical to the text path.
replyText := h.runTurn(ctx, text, sourceVoice)
replyText := h.runTurn(ctx, router.NormalizedInput{Text: text, Source: sourceVoice})
// 6. tts — synthesise the reply text; return to the voice server which
// ships it back on the conn.
@@ -244,30 +244,30 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
// HandlePushToTalk so text channels share the same routing logic.
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
log.Printf("voice: handleText: %q", text)
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), router.NormalizedInput{Text: text, Source: sourceText})
}
// turnSource — which channel this utterance arrived on, in the same provenance
// vocabulary facts use (internal/event). It is threaded through runTurn 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 turnSource string
// turnSource is a local alias for router.InputSource, kept so the daemon code
// reads sourceVoice/sourceText without a package prefix at every call site.
// The canonical type lives in the router package; this is pure convenience.
type turnSource = router.InputSource
const (
sourceVoice turnSource = "tap:voice" // HandlePushToTalk, a real microphone
sourceText turnSource = "tap:text" // handleText: mavweb /api/chat, telegram
sourceVoice = router.InputSourceVoice
sourceText = router.InputSourceText
)
// runTurn — the reactive turn pipeline shared by the voice and text entry
// points: expired-clarify notice → confirm answer → explicit correction →
// clarify answer → quiet toggle → reminder cancellation → route → dialogue
// merge → clarify question → action → replier.
// Takes the already-transcribed utterance, returns the reply text; the voice
// path wraps it in stt/tts, the text path returns it as-is.
// Takes the NormalizedInput (typed ingress boundary), returns the reply text;
// the voice path wraps it in stt/tts, the text path returns it as-is.
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
func (h *reactiveHandler) runTurn(ctx context.Context, input router.NormalizedInput) (reply string) {
text := input.Text
src := input.Source
// 0. the decision record (V-564). Installed here rather than in the IPC
// entry point, so the mic, telegram and the web all leave the same trail —
// a record only the web produced would be missing exactly the turns that
@@ -275,7 +275,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// on a human-rate path, and no claim site can change a route with it.
if h.decisions != nil {
var rec *decision.Record
ctx, rec = decision.With(ctx, text)
ctx, rec = decision.With(ctx, text, string(src))
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
defer func() {
done := rec.Finish(h.now())
@@ -289,7 +289,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// claiming it, and step 5 acts on the same decision — routing twice would
// cost a second on the resident model and could disagree with itself.
now := h.now()
rt := h.newTurnRoute(text, now)
rt := h.newTurnRoute(input, now)
ctx = withTurnRoute(ctx, rt)
// A resolver may suspend an older clarify flow even when it handles this
// turn itself. Finalise that state at one choke point so early returns from
@@ -414,6 +414,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
return withNotice(expiredNotice, "не получилось разобрать команду.")
}
log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots)
// Carry the route producer into the decision record for observability.
if rec := decision.From(ctx); rec != nil && dec.Producer != "" {
rec.RouteProducer = string(dec.Producer)
}
// 7. dialogue — fill this turn's missing slots from a prior same-intent
// turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember
+14 -3
View File
@@ -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
}
+16
View File
@@ -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
+4
View File
@@ -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.
+23
View File
@@ -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
}
+5
View File
@@ -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
+10 -7
View File
@@ -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()