100 lines
3.3 KiB
Go
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)
|
|
}
|