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
+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
}