Files
claude 869580c913 a reminder said whole no longer asks "Когда?" (V-572)
"напомни в 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."
2026-08-06 01:08:38 +04:00

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)
}
}