package router import ( "context" "testing" "time" ) func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } // seedClassifier — the spec's "~10 examples/intent" bootstrap, trimmed for the // test. Real surface words ⇒ the hash embedder gives same-words-similar-vectors // ⇒ the centroid math routes correctly without the ONNX model. func seedClassifier(t *testing.T, c *Classifier) { t.Helper() ctx := context.Background() acts := []string{"restart nginx", "restart the backup", "stop nginx", "run the backup now"} reminders := []string{"remind me at seven", "wake me at seven", "remind me in four hours", "wake me tuesday"} 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) } } for _, x := range reminders { if err := c.AddExample(ctx, IntentReminder, x); err != nil { t.Fatalf("seed reminder %q: %v", x, err) } } for _, x := range facts { if err := c.AddExample(ctx, IntentFact, x); err != nil { t.Fatalf("seed fact %q: %v", x, err) } } for _, x := range notes { if err := c.AddExample(ctx, IntentNote, x); err != nil { t.Fatalf("seed note %q: %v", x, err) } } for _, x := range queries { if err := c.AddExample(ctx, IntentQuery, x); err != nil { 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 { t.Helper() // dim 1024: a hash embedder is bag-of-words, so collisions across intents // would mask the real centroid math. 1024 buckets over ~40 tokens makes // collisions negligible — the test exercises the cascade, not the hash. emb := NewHashEmbedder(1024) c := NewClassifier(emb) seedClassifier(t, c) acts := DefaultActMatcher{Fns: []string{"restart", "stop", "run", "backup"}} ex := Extractor{ Time: StubDateTimeParser{}, Acts: acts, Facts: DefaultFactParser{}, } return New(Config{ Grammars: DefaultGrammars(acts), Classifier: c, Extractor: ex, Threshold: threshold, }) } // ----------------------------- stage 0 --------------------------------------- func TestStage0WakeWordAct(t *testing.T) { r := newTestRouter(t, 0.0) d, err := r.Route(context.Background(), "maven, restart nginx", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Stage != 0 || d.Intent != IntentAct || d.Confidence != 1.0 { t.Fatalf("stage0: want stage=0 act conf=1.0, got %+v", d) } if !d.Slots.HasFn || d.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" { t.Fatalf("stage0 slots: want fn=restart args=[nginx], got %+v", d.Slots) } } func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) { // wakeword prefix alone doesn't guarantee a known command. "maven, i'm tired" // is a fact-ish utterance → falls through to the classifier. r := newTestRouter(t, 0.0) d, err := r.Route(context.Background(), "maven, i drank water", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Stage == 0 { t.Fatalf("unknown act should fall through, got stage0 %+v", d) } } 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) { r := newTestRouter(t, 0.0) d, err := r.Route(context.Background(), "restart the backup now", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Intent != IntentAct { t.Fatalf("want act, got %s (conf %f)", d.Intent, d.Confidence) } if !d.Slots.HasFn || d.Slots.Fn != "restart" { t.Fatalf("act slots: want fn=restart, got %+v", d.Slots) } } func TestStage1ClassifiesFact(t *testing.T) { r := newTestRouter(t, 0.0) d, err := r.Route(context.Background(), "i drank water", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Intent != IntentFact { t.Fatalf("want fact, got %s (conf %f)", d.Intent, d.Confidence) } if !d.Slots.HasKey || d.Slots.Key != "water" { t.Fatalf("fact slots: want key=water, got %+v", d.Slots) } } func TestStage1ClassifiesNoteAndQuery(t *testing.T) { r := newTestRouter(t, 0.0) cases := []struct { in string want Intent }{ {"prefer backups at three am", IntentNote}, {"gpu driver fixed the flicker", IntentNote}, {"is the backup up", IntentQuery}, {"when did i last eat", IntentQuery}, } 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) } } } // ----------------------------- stage 2 --------------------------------------- func TestStage2ReminderSlotExtraction(t *testing.T) { r := newTestRouter(t, 0.0) now := refNow() d, err := r.Route(context.Background(), "remind me in four hours", now) if err != nil { t.Fatalf("route: %v", err) } if d.Intent != IntentReminder { t.Fatalf("want reminder, got %s", d.Intent) } if !d.Slots.HasTime { t.Fatalf("reminder: want HasTime, got %+v", d.Slots) } want := now.Add(4 * time.Hour) if !d.Slots.Time.Equal(want) { t.Fatalf("reminder time: want %v, got %v", want, d.Slots.Time) } } func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) { // "wake me at 7" said at 12:00 → fires tomorrow 07:00 (already past today). r := newTestRouter(t, 0.0) now := refNow() d, err := r.Route(context.Background(), "wake me at 7", now) if err != nil { t.Fatalf("route: %v", err) } if d.Intent != IntentReminder { t.Fatalf("want reminder, got %s", d.Intent) } want := time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC) if !d.Slots.Time.Equal(want) { t.Fatalf("wake-at-7: want %v, got %v", want, d.Slots.Time) } } func TestStage2FactSleptDuration(t *testing.T) { r := newTestRouter(t, 0.0) d, err := r.Route(context.Background(), "slept 6h", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Intent != IntentFact { t.Fatalf("want fact, got %s", d.Intent) } if d.Slots.Key != "sleep" || d.Slots.Value != `"6h"` { t.Fatalf("slept slots: want key=sleep value=\"6h\", got %+v", d.Slots) } } // ----------------------------- stage 3 --------------------------------------- func TestStage3ClarifyBelowThreshold(t *testing.T) { // high threshold ⇒ even a well-classified utterance is gated to clarify. // "shuts up when uncertain": a misrouted fact is a confident wrong write. r := newTestRouter(t, 0.99) d, err := r.Route(context.Background(), "i drank water", refNow()) if err != nil { t.Fatalf("route: %v", err) } if !d.Clarify || d.Stage != 3 { t.Fatalf("want stage3 clarify, got stage=%d clarify=%v (conf %f)", d.Stage, d.Clarify, d.Confidence) } // the best-guess intent + slots still travel with the decision so the // clarify prompt can use them ("did you mean — you drank water?") if d.Intent != IntentFact { t.Fatalf("clarify should still carry best guess, got %s", d.Intent) } } func TestStage3PassesAboveThreshold(t *testing.T) { r := newTestRouter(t, 0.0) // threshold 0 ⇒ nothing gated d, err := r.Route(context.Background(), "i drank water", refNow()) if err != nil { t.Fatalf("route: %v", err) } if d.Clarify { t.Fatalf("threshold 0 should never clarify, got %+v", d) } } // ----------------------------- cold boot ------------------------------------- func TestColdBootNoIntents(t *testing.T) { // unseeded classifier → free-form input cannot be routed. stage 0 still // works (grammar path). "shuts up when uncertain" for routing. emb := NewHashEmbedder(128) c := NewClassifier(emb) r := New(Config{Classifier: c, Threshold: 0}) if _, err := r.Route(context.Background(), "something freeform", refNow()); err != ErrNoIntents { t.Fatalf("cold boot: want ErrNoIntents, got %v", err) } } // ----------------------------- misroute correction --------------------------- func TestCorrectMisrouteGrowsClassifier(t *testing.T) { r := newTestRouter(t, 0.4) // "note the backup is broken" looks note-ish but the user meant a fact // (loop should know the backup is down). Without correction it routes note. before, err := r.Route(context.Background(), "backup is broken", refNow()) if err != nil { t.Fatalf("route: %v", err) } if before.Intent == IntentFact { t.Fatalf("precondition: expected non-fact, got %s", before.Intent) } // user corrects → append a new example for the corrected intent. if err := r.CorrectMisroute(context.Background(), "backup is broken", IntentFact); err != nil { t.Fatalf("correct: %v", err) } // a few reinforcements so the centroid shifts decisively. for _, x := range []string{"backup is down", "backup failed", "backup broken now"} { if err := r.CorrectMisroute(context.Background(), x, IntentFact); err != nil { t.Fatalf("correct: %v", err) } } after, err := r.Route(context.Background(), "backup is broken", refNow()) if err != nil { t.Fatalf("route: %v", err) } if after.Intent != IntentFact { t.Fatalf("after correction: want fact, got %s (conf %f)", after.Intent, after.Confidence) } } // ----------------------------- classifier unit ------------------------------- func TestClassifierDeterministicOrdering(t *testing.T) { emb := NewHashEmbedder(64) c := NewClassifier(emb) ctx := context.Background() _ = c.AddExample(ctx, IntentAct, "restart nginx") _ = c.AddExample(ctx, IntentFact, "drank water") r1, _ := c.Classify(ctx, "restart nginx") r2, _ := c.Classify(ctx, "restart nginx") if len(r1) != len(r2) { t.Fatalf("non-deterministic length") } for i := range r1 { if r1[i] != r2[i] { t.Fatalf("non-deterministic ordering at %d: %v vs %v", i, r1[i], r2[i]) } } if r1[0].Intent != IntentAct { t.Fatalf("best match should be act, got %s", r1[0].Intent) } } 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) ctx := context.Background() _ = c.AddExample(ctx, IntentQuery, "q") _ = c.AddExample(ctx, IntentAct, "a") _ = c.AddExample(ctx, IntentFact, "f") got := c.Intents() want := []Intent{IntentAct, IntentFact, IntentQuery} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { t.Fatalf("intents sort: want %v, got %v", want, got) } }