869580c913
"напомни в 11:00 позвонить маме" answered "Когда?" about an hour he had just said. ReminderGrammar builds its slots by hand and the router ran no extraction over a stage-0 decision, so HasTime was false however plainly the hour was spoken; missingFor read the silence as absence. The fix runs the stage-2 extractor over every stage-0 decision, filling only the slots the grammar left empty. A matched value always wins: the rule read a literal pattern, the extractor guesses. This is the same hole the LLM path already had, so fillSlots and the new stage-0 call share one fillMatchedSlots. Enabled for all ten grammars rather than a chosen few, because for every intent but reminder it is inert. Extract fills Time for a reminder, Fn for an act and Key for a fact, and nothing at all for query, system, note or chat — which is what the clock, agenda, feed, list, task, Praxis-adjacent and narrative rules emit. The two act rules, wakeword-act and the Praxis ones, already carry an Fn or they do not match, so the matcher has nothing left to fill. Measured rather than asserted: benchmarked at 20000x, a stage-0 query is 3.7µs against 3.9µs before and a clock or act rule is 0.7µs either way, both inside the noise. The reminder rule is the one that gains, and its date parse is not new spend — actionReminder was already running exactly that parse one layer down, and now skips it. Slots.Text is deliberately not filled. Extract sets it to the raw utterance, and a grammar that left it empty meant it: agendaQueryBuild hands the query chain the sentence itself, and narrativeQueryBuild's Text is the topic. Fixture unchanged at 64/91 (70.3%) on TestONNXBaseline, no case regressed, no new false clarify. What moved is the line the fixture calls "slots deferred to daemon": 6 to 0. Verified on homesrv: "напомни в 11:00 позвонить маме" now answers "хорошо, напомню сегодня в 11:00."
429 lines
15 KiB
Go
429 lines
15 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"}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestStage0ReminderCarriesTheHourHeSaid — "напомни в 11:00 позвонить маме" is
|
|
// the commonest reminder there is, and it used to reach the daemon with HasTime
|
|
// false, because ReminderGrammar builds its slots by hand and the router ran no
|
|
// extraction over a stage-0 decision. The daemon read the silence as absence and
|
|
// asked "Когда?" about an hour he had just said (V-572).
|
|
func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) {
|
|
r := newTestRouter(t, 0.0)
|
|
r.grammars = append(r.grammars, ReminderGrammar())
|
|
|
|
d, err := r.Route(context.Background(), "напомни в 11:00 позвонить маме", refNow())
|
|
if err != nil {
|
|
t.Fatalf("route: %v", err)
|
|
}
|
|
if d.Stage != 0 || d.Intent != IntentReminder {
|
|
t.Fatalf("want stage0 reminder, got %+v", d)
|
|
}
|
|
if !d.Slots.HasTime {
|
|
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", d.Slots)
|
|
}
|
|
if got, want := d.Slots.Time.Format("15:04"), "11:00"; got != want {
|
|
t.Errorf("fire time = %s, want %s", got, want)
|
|
}
|
|
// The subject is the grammar's, not the extractor's: Slots.Text is what she
|
|
// says at the hour, and Extract would have overwritten it with the sentence.
|
|
if d.Slots.Text != "в 11:00 позвонить маме" {
|
|
t.Errorf("Text = %q, want the grammar's capture", d.Slots.Text)
|
|
}
|
|
}
|
|
|
|
// TestStage0MatchedSlotBeatsTheExtractor — a grammar that matched a literal
|
|
// pattern outranks a parser that guessed. The wake-word act names its fn from
|
|
// the remainder after the wake token; extraction over the raw utterance must not
|
|
// be able to replace it.
|
|
func TestStage0MatchedSlotBeatsTheExtractor(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.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" {
|
|
t.Fatalf("matched fn was overwritten: %+v", d.Slots)
|
|
}
|
|
if d.Slots.Text != "restart nginx" {
|
|
t.Errorf("Text = %q, want the grammar's remainder", d.Slots.Text)
|
|
}
|
|
}
|
|
|
|
// TestStage0QueryKeepsAnEmptyText — agendaQueryBuild deliberately leaves Text
|
|
// empty so the query chain reads the utterance itself. Extraction fills Time,
|
|
// Key and Fn and never Text, or every stage-0 query would start carrying the
|
|
// whole sentence in a slot that means something narrower.
|
|
func TestStage0QueryKeepsAnEmptyText(t *testing.T) {
|
|
r := newTestRouter(t, 0.0)
|
|
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
|
|
|
d, err := r.Route(context.Background(), "что у меня сегодня", refNow())
|
|
if err != nil {
|
|
t.Fatalf("route: %v", err)
|
|
}
|
|
if d.Stage != 0 || d.Intent != IntentQuery {
|
|
t.Fatalf("want stage0 query, got %+v", d)
|
|
}
|
|
if d.Slots.Text != "" {
|
|
t.Errorf("Text = %q, want it left empty", d.Slots.Text)
|
|
}
|
|
}
|
|
|
|
// ----------------------------- 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)
|
|
}
|
|
}
|