Files
Maven/internal/router/router.go
T
kami 28a940ebbe feat: LLM router with chat intent and Cyrillic wake-word support
- Add LLMRouter: grammar-constrained LFM call for intent classification
  after stage-0, before classifier cascade. Errors fall through gracefully.
- Add IntentChat: conversational intent with no store side-effect, routed
  through LLM -> phraser chat endpoint.
- Extract slots for Chat: no structured slots, full utterance is payload.
- Extend stage-0 grammars to fire through Cyrillic wake-word spellings
  (Мэйвен/Мейвен/Майвен/etc.) produced by Russian STT model.
- StripWakeToken helper strips leading wake in any script so time/date
  grammars still match when wake is present.
- Add classifier examples for chat utterances (EN + RU).
- Wire LLMRouter into Router.Config; optional, nil-safe.
2026-07-10 15:48:48 +04:00

127 lines
4.4 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 LFM call. 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
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
}
// 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)
}