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.
This commit is contained in:
kami
2026-07-10 15:48:48 +04:00
parent 6bab68e96d
commit 28a940ebbe
8 changed files with 280 additions and 4 deletions
+27
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"log"
"time"
)
@@ -21,6 +22,11 @@ type Config struct {
// 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
@@ -31,6 +37,7 @@ type Router struct {
classifier *Classifier
extractor Extractor
threshold float64
llm *LLMRouter
}
func New(cfg Config) *Router {
@@ -39,6 +46,7 @@ func New(cfg Config) *Router {
classifier: cfg.Classifier,
extractor: cfg.Extractor,
threshold: cfg.Threshold,
llm: cfg.LLM,
}
}
@@ -53,8 +61,15 @@ func New(cfg Config) *Router {
// 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
}
@@ -66,6 +81,18 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
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 {