36bc603f52
Found verifying the three fixes on the box: "кто изобрёл телефон" ran a LAN scan and answered "нашла 3 устройства". The network seed set opens with "кто в сети сейчас" and names devices throughout, so a "кто ..." question about any device noun landed there. Three topicOther seeds, same shape as the V-553 fix. TestONNXTopics 34/34 -> 38/38 on held-out utterances, and a real scan is still a scan.
163 lines
8.1 KiB
Go
163 lines
8.1 KiB
Go
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},
|
||
{"что нового в лентах?", topicFeed, feedFloor, true},
|
||
{"что в списке покупок?", topicList, listFloor, 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},
|
||
{"что нового в ленте за сегодня", topicFeed, feedFloor},
|
||
{"какие сегодня заголовки", topicFeed, feedFloor},
|
||
{"что нового про искусственный интеллект", topicFeed, feedFloor},
|
||
// The greeting. It has to lose to topicOther, or fall thin enough that
|
||
// ParseFeedQuery — which declines a vague noun with no topic — answers.
|
||
{"что нового?", topicOther, feedFloor},
|
||
{"что мне надо купить в магазине", topicList, listFloor},
|
||
{"прочитай мне список", topicList, listFloor},
|
||
{"что там в аптеке нужно взять", topicList, listFloor},
|
||
// A task read-back is not a list read-back, and the two collide on
|
||
// "что у меня".
|
||
{"какие у меня сейчас задачи", topicOther, listFloor},
|
||
// World questions that name a day (Vikunja #553). Weather was the only
|
||
// topic carrying day words, so all of these read as weather and two of
|
||
// them cleared the margin: the gate asked "для какого города?" about
|
||
// the dollar. The last two are far from any seed on purpose — the
|
||
// first three are close enough to the new topicOther seeds that they
|
||
// would pass on similarity alone.
|
||
{"какой сегодня курс доллара", topicOther, isWeatherQuery},
|
||
{"какой сегодня праздник", topicOther, isWeatherQuery},
|
||
{"что интересного произошло сегодня в мире", topicOther, isWeatherQuery},
|
||
{"во сколько завтра открывается музей", topicOther, isWeatherQuery},
|
||
{"кто сегодня играет в лиге чемпионов", topicOther, isWeatherQuery},
|
||
// The control the seeds above must not cost: real weather still reads
|
||
// as weather, including the two that lean on the keyword floor.
|
||
{"будет ли завтра дождь в москве", topicWeather, isWeatherQuery},
|
||
{"какая температура завтра утром", topicWeather, isWeatherQuery},
|
||
// The same shape one topic over, seen on the box (Vikunja #554): a
|
||
// device has a history, and asking about it is not asking what is
|
||
// plugged in. "кто изобрёл телефон" answered "нашла 3 устройства".
|
||
// Held out from the seeds, which name the telephone and the computer.
|
||
{"кто придумал радио", topicOther, isNetworkQuery},
|
||
{"когда изобрели телевизор", topicOther, isNetworkQuery},
|
||
{"как устроен телефон внутри", topicOther, isNetworkQuery},
|
||
// The control: a real scan is still a scan.
|
||
{"какие устройства подключены к вайфаю", topicNetwork, isNetworkQuery},
|
||
}
|
||
|
||
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))
|
||
}
|