package main import ( "context" "os" "path/filepath" "testing" "github.com/kami/maven/internal/router" ) // TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the // seeds, and every topic source has to keep working. This is the case that used // to be the only one, so a regression here is all four recognisers going silent // on a box with no embedder at all. func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { h := &reactiveHandler{} for _, tc := range []struct { utterance string label topicLabel floor func(string) bool want bool }{ {"какая сегодня погода", topicWeather, isWeatherQuery, true}, {"что включено в доме?", topicHome, isHomeQuery, true}, {"какие устройства в сети?", topicNetwork, isNetworkQuery, true}, {"что требует внимания?", topicAttend, isAttentionQuery, true}, {"почему небо синее", topicWeather, isWeatherQuery, false}, {"я дома", topicHome, isHomeQuery, false}, {"интернет не работает", topicNetwork, isNetworkQuery, false}, } { turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}} if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want { t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want) } } } // TestONNXTopics — the number that matters, scored against the embedder homesrv // actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary. // // Every case is held out: none of these strings is a seed. It asserts what the // gate does, not what the raw scorer says — a label under topicMargin is handed // to the keyword floor, and one case turns on exactly that. // // The first three rows are the collisions the old regexes needed hand-written // bail-outs for: the temperature pair that made isHomeQuery exclude weather // words, and the "посетил" substring that made isNetworkQuery match "сети" as a // whole token. func TestONNXTopics(t *testing.T) { lib := os.Getenv("MAVEN_ONNX_LIB") if lib == "" { t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") } dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) if err != nil { t.Skipf("onnx embedder unavailable: %v", err) } defer emb.Close() cases := []struct { utterance string want topicLabel floor func(string) bool }{ {"какая температура на улице?", topicWeather, isWeatherQuery}, {"какая температура в доме?", topicHome, isHomeQuery}, {"сколько машин я посетил?", topicOther, nil}, {"сколько сейчас градусов", topicWeather, isWeatherQuery}, {"дождь будет вечером?", topicWeather, isWeatherQuery}, {"тепло сегодня на улице?", topicWeather, isWeatherQuery}, {"свет на кухне включен?", topicHome, isHomeQuery}, {"что сейчас включено дома", topicHome, isHomeQuery}, {"датчики в квартире что показывают", topicHome, isHomeQuery}, {"просканируй сеть", topicNetwork, isNetworkQuery}, {"сколько устройств в локальной сети", topicNetwork, isNetworkQuery}, {"кто сейчас в сетке", topicNetwork, isNetworkQuery}, // The case the margin exists for. It reads as network by 0.0055, and // isNetworkQuery says no, so it stays the complaint it is — a scan she // runs unasked is the behaviour the bounds prevent. {"вайфай опять отвалился", topicOther, isNetworkQuery}, {"я уже приехал домой", topicOther, nil}, {"что я говорил про погоду в москве", topicOther, nil}, {"напомни полить цветы", topicOther, nil}, {"что требует моего внимания сейчас", topicAttend, isAttentionQuery}, {"что не так с базой данных", topicAttend, isAttentionQuery}, {"есть что-то срочное на сегодня", topicAttend, isAttentionQuery}, } h := &reactiveHandler{recall: recallWiring{embedder: emb}} ctx := context.Background() h.recall.topics.load(ctx, emb) if !h.recall.topics.loaded { t.Fatal("topic seeds did not load") } right := 0 for _, tc := range cases { vec, err := router.EmbedQuery(ctx, emb, tc.utterance) if err != nil { t.Fatalf("embed %q: %v", tc.utterance, err) } label, margin, ok := h.recall.topics.best(vec) if !ok { t.Fatalf("best(%q) not ok", tc.utterance) } // What the gate would do, which is the thing under test: a label that // does not clear the margin is handed to that source's keyword floor. got := label if margin < topicMargin { got = topicOther if tc.floor != nil && tc.floor(tc.utterance) { got = label } } if got == tc.want { right++ } else { t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want) } t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin) } t.Logf("topics: %d/%d", right, len(cases)) }