316 lines
10 KiB
Go
316 lines
10 KiB
Go
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"}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ----------------------------- 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 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)
|
|
}
|
|
}
|