Files
Maven/internal/router/router.go
T
2026-07-03 00:32:48 +02:00

100 lines
3.3 KiB
Go

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