c31f0d1001
An LLM-routed reminder came back with no parsed time and an act with no fn, because only the classifier path ran the extractor. Now the router runs the same extraction after an LLM decision and fills only the empty slots. No time in the utterance still means no time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
162 lines
6.0 KiB
Go
162 lines
6.0 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"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
|
|
// LLM — optional agentic router. When set, Route consults it after stage-0
|
|
// and before the classifier cascade, classifying the utterance via a
|
|
// grammar-constrained call to the resident model (Qwen3-1.7B). On any
|
|
// error/parse failure, falls through to the classifier (never fails the
|
|
// turn on the model).
|
|
LLM *LLMRouter
|
|
}
|
|
|
|
// 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
|
|
llm *LLMRouter
|
|
}
|
|
|
|
func New(cfg Config) *Router {
|
|
return &Router{
|
|
grammars: cfg.Grammars,
|
|
classifier: cfg.Classifier,
|
|
extractor: cfg.Extractor,
|
|
threshold: cfg.Threshold,
|
|
llm: cfg.LLM,
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// 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(utterance)
|
|
for _, g := range r.grammars {
|
|
m := g.Pattern.FindStringSubmatch(utterance)
|
|
if m == nil && hadWake {
|
|
m = g.Pattern.FindStringSubmatch(stripped)
|
|
}
|
|
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 1a — LLM router (when wired). It reasons over the utterance instead
|
|
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
|
|
// classifier cascade (never fail the turn on the model).
|
|
if r.llm != nil {
|
|
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
|
|
d.Utterance = utterance
|
|
r.fillSlots(ctx, &d, now)
|
|
return d, nil
|
|
} else if err != nil {
|
|
log.Printf("router: llm route fell back to classifier: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// fillSlots — run stage-2 extraction on an LLM decision and fill only the slots
|
|
// the model left empty. The LLM wins where it answered: it saw the sentence, the
|
|
// parsers are keyword tables. Extraction covers what the model cannot produce at
|
|
// all — a parsed reminder time and an allowlist fn.
|
|
//
|
|
// If a reminder still has no time, leave it missing. The daemon then says it
|
|
// could not read the time; inventing one would set a wrong alarm.
|
|
func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
|
ex := r.extractor.Extract(ctx, d.Intent, d.Utterance, now)
|
|
if !d.Slots.HasTime && ex.HasTime {
|
|
d.Slots.Time, d.Slots.HasTime = ex.Time, ex.HasTime
|
|
}
|
|
if !d.Slots.HasKey && ex.HasKey {
|
|
d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey
|
|
}
|
|
if !d.Slots.HasFn && ex.HasFn {
|
|
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn
|
|
}
|
|
// For an act the model returns the verb in Text ("restart nginx"), which is
|
|
// often cleaner than the raw utterance ("maven, could you restart nginx").
|
|
// Try it too when the utterance did not match the allowlist.
|
|
if d.Intent == IntentAct && !d.Slots.HasFn && r.extractor.Acts != nil &&
|
|
d.Slots.Text != "" && d.Slots.Text != d.Utterance {
|
|
if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok {
|
|
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
|
}
|
|
}
|
|
if d.Slots.Text == "" {
|
|
d.Slots.Text = ex.Text
|
|
}
|
|
// Stage stays 1: it says who decided the route, and that was the LLM.
|
|
}
|
|
|
|
// 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)
|
|
}
|