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
+46
View File
@@ -19,6 +19,7 @@ func seedClassifier(t *testing.T, c *Classifier) {
facts := []string{"drank water", "i drank water", "ate lunch", "slept six hours", "had a meal"}
notes := []string{"gpu driver fixed the flicker", "prefer backups at three am", "note that the router reboots on tuesday"}
queries := []string{"is the backup up", "when did i last eat", "is nginx running", "how much water today"}
chats := []string{"what do you think about", "tell me something interesting", "how are you", "расскажи что-нибудь", "что ты думаешь", "как дела"}
for _, x := range acts {
if err := c.AddExample(ctx, IntentAct, x); err != nil {
t.Fatalf("seed act %q: %v", x, err)
@@ -44,6 +45,11 @@ func seedClassifier(t *testing.T, c *Classifier) {
t.Fatalf("seed query %q: %v", x, err)
}
}
for _, x := range chats {
if err := c.AddExample(ctx, IntentChat, x); err != nil {
t.Fatalf("seed chat %q: %v", x, err)
}
}
}
func newTestRouter(t *testing.T, threshold float64) *Router {
@@ -97,6 +103,24 @@ func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) {
}
}
func TestStage0GrammarFiresThroughCyrillicWakeWord(t *testing.T) {
// The STT is a Russian model — it transcribes the spoken wake word
// phonetically ("Мэйвен"), never as the Latin "maven". Grammars that
// don't expect a wake prefix (time/date) must still fire when one is
// there, otherwise these fall through to the classifier and get
// misrouted into IntentReminder (see stage0.go's wakeToken comment).
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
d, err := r.Route(context.Background(), "Мэйвен который час", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Stage != 0 || d.Intent != IntentSystem {
t.Fatalf("want stage0 system, got %+v", d)
}
}
// ----------------------------- stage 1 ---------------------------------------
func TestStage1ClassifiesAct(t *testing.T) {
@@ -300,6 +324,28 @@ func TestClassifierDeterministicOrdering(t *testing.T) {
}
}
func TestStage1ClassifiesChat(t *testing.T) {
r := newTestRouter(t, 0.0)
cases := []struct {
in string
want Intent
}{
{"what do you think about ai", IntentChat},
{"tell me something interesting", IntentChat},
{"как дела", IntentChat},
{"расскажи что-нибудь", IntentChat},
}
for _, c := range cases {
d, err := r.Route(context.Background(), c.in, refNow())
if err != nil {
t.Fatalf("route %q: %v", c.in, err)
}
if d.Intent != c.want {
t.Errorf("%q: want %s, got %s (conf %f)", c.in, c.want, d.Intent, d.Confidence)
}
}
}
func TestClassifierIntentsSorted(t *testing.T) {
emb := NewHashEmbedder(32)
c := NewClassifier(emb)