From 2ef470c2ba6a6f76c0e7699de826ed42e3838427 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 01:24:25 +0400 Subject: [PATCH] router: add TryFastPath with stage-0 grammar evaluation (slice 10) --- internal/router/fastpath.go | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 internal/router/fastpath.go diff --git a/internal/router/fastpath.go b/internal/router/fastpath.go new file mode 100644 index 0000000..6221baf --- /dev/null +++ b/internal/router/fastpath.go @@ -0,0 +1,75 @@ +package router + +import ( + "context" + "time" +) + +// FastPathResult is the outcome of the stage-0 deterministic fast path. +// Matched is true when a grammar accepted the input. Decision carries the +// full routing result for a match. When Matched is false the caller falls +// through to the general cascade (heads → LLM → classifier → extraction → gate). +type FastPathResult struct { + Matched bool + Decision Decision +} + +// TryFastPath runs the stage-0 exact-match grammars. It owns the ordered +// grammar list, first-match-wins semantics, wake-token alternate handling, +// slot filling, capability selection, and per-grammar trace recording. +// +// When no grammar matches, Matched is false and the caller falls through +// to the general cascade. +// +// TryFastPath receives NormalizedInput so later recognizers can opt into +// MatchText, but no existing grammar switches in this slice — they all +// receive input.Text (the raw utterance) exactly as they do today. +func (r *Router) TryFastPath( + ctx context.Context, + input NormalizedInput, + now time.Time, +) (FastPathResult, error) { + // stage 0 — exact match / grammar. First match wins; grammars are ordered. + // Grammars like time/date/reminder don't expect a wake-word prefix, but + // the STT often includes one (transcribed phonetically, any script) — try + // the wake-stripped utterance too so those grammars still fire. + stripped, hadWake := StripWakeToken(input.Text) + // declinedBuild — the grammars that matched the shape and refused the + // content, kept for the decision record (V-564) so a reader can tell that + // rule from one whose pattern never fired. + var declinedBuild map[int]bool + for i, g := range r.grammars { + d, matched, ok := g.Evaluate(input.Text) + if !matched && hadWake { + d, matched, ok = g.Evaluate(stripped) + } + if !matched { + continue + } + if !ok { + if declinedBuild == nil { + declinedBuild = map[int]bool{} + } + declinedBuild[i] = true + continue // grammar matched shape but not content → fall through + } + d.Utterance = input.Text + 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. + d.SourceAnchored = d.Source != SourceUnknown + // The grammar decided the intent; the extractor fills the slots it did + // not match (V-572). See fillMatchedSlots for why every grammar gets it. + r.fillMatchedSlots(ctx, &d, now) + // Capability selection: deterministic grammars that already resolved Fn + // (wakeword-act, praxis, task-status) bypass the general selector. + // SelectCapability records the existing result with + // InputKind=SelectionDeterministic. + applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts)) + r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent) + return FastPathResult{Matched: true, Decision: d}, nil + } + r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "") + return FastPathResult{}, nil +}