package router import ( "context" "time" ) // Config — wires the cascade. Build via New; a zero-value Router is unusable. type Config struct { // Grammars — stage-0 exact-match rules. DefaultGrammars(actMatcher) wires // the wake-word act fast path; the daemon may append more. Grammars []Grammar // Classifier — stage-1 nearest-centroid classifier. Must be seeded with // ~10 examples/intent at bootstrap (per spec) before free-form routing // is trustworthy; until then Route returns ErrNoIntents on free-form input. Classifier *Classifier // Extractor — stage-2 per-intent slot extraction. Any nil sub-parser just // leaves the corresponding Has* flag false for that intent. Extractor Extractor // Threshold — stage-3 confidence gate. Below ⇒ Clarify, don't guess. The // spec leaves this open (defines how often maven asks vs guesses on free- // form input; the whole reactive mvp feel rides on it). The daemon sets it. Threshold float64 } // Router — the deterministic cascade. Route never guesses: stage 0 wins // outright, stage 1 scores, stage 2 extracts, stage 3 gates. The SLM only // phrases the reply — it never owns the route. type Router struct { grammars []Grammar classifier *Classifier extractor Extractor threshold float64 } func New(cfg Config) *Router { return &Router{ grammars: cfg.Grammars, classifier: cfg.Classifier, extractor: cfg.Extractor, threshold: cfg.Threshold, } } // Route — the cascade: stage 0 (exact match) → 1 (classify) → 2 (extract) → // 3 (confidence gate). // // Stage 0 wins outright: returns at confidence 1.0, no classifier. // Otherwise the classifier scores every intent; the best wins; slots are // extracted for that intent. If the winning score < threshold the Decision // is flagged Clarify (the daemon asks rather than guesses — same shape as // since(key)==null → don't fire: a misrouted fact is a confident wrong write, // worse than a gap). func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (Decision, error) { // stage 0 — exact match / grammar. First match wins; grammars are ordered. for _, g := range r.grammars { m := g.Pattern.FindStringSubmatch(utterance) if m == nil { continue } d, ok := g.Build(m) if !ok { continue // grammar matched shape but not content → fall through } d.Utterance = utterance return d, nil } // stage 1 — intent classifier. results, err := r.classifier.Classify(ctx, utterance) if err != nil { return Decision{}, err } best := results[0] // stage 2 — slot extraction for the winning intent. d := Decision{ Utterance: utterance, Stage: 2, Intent: best.Intent, Confidence: best.Score, Slots: r.extractor.Extract(ctx, best.Intent, utterance, now), } // stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess. if d.Confidence < r.threshold { d.Stage = 3 d.Clarify = true } return d, nil } // CorrectMisroute — the user corrected a bad classification. Appends a new // example for the corrected intent (append-only — grows the classifier, no // retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over // time, introspectable, no model surgery. func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error { return r.classifier.AddExample(ctx, corrected, utterance) }