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 the three 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}, {"почему небо синее", 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 not a // claim, and one held-out 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 }{ {"какая температура на улице?", topicWeather}, {"какая температура в доме?", topicHome}, {"сколько машин я посетил?", topicOther}, {"сколько сейчас градусов", topicWeather}, {"дождь будет вечером?", topicWeather}, {"тепло сегодня на улице?", topicWeather}, {"свет на кухне включен?", topicHome}, {"что сейчас включено дома", topicHome}, {"датчики в квартире что показывают", topicHome}, {"просканируй сеть", topicNetwork}, {"сколько устройств в локальной сети", topicNetwork}, {"кто сейчас в сетке", topicNetwork}, // The case the margin exists for. It reads as network by 0.0055, under // topicMargin, so the gate passes it on — which is right: it is a // complaint, and a scan she runs unasked is the behaviour the bounds // prevent. {"вайфай опять отвалился", topicOther}, {"я уже приехал домой", topicOther}, {"что я говорил про погоду в москве", topicOther}, {"напомни полить цветы", topicOther}, } h := &reactiveHandler{embedder: emb} ctx := context.Background() h.topics.load(ctx, emb) if !h.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.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 not a claim. got := label if margin < topicMargin { got = topicOther } 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)) }