a08403087f
internal/router/list.go was the last file on the sweep, and the answer is a split rather than one mechanism. What the four paths need is different, and the V-529 comment in the file already had half of the argument. Reading a list back needs one bit — is this about the list — so topicList joins the subjects in cmd/mavend/topics.go and queryList calls turnIsAbout. listQueryPrefixes stays as the offline floor. Which list he named is a noun in the dictionary either way, through the new router.ListNamedIn, which scans the whole utterance: the seeds claim a read-back without eating a prefix, so "что мне нужно в аптеке" has nothing for takeListTag to read the front of. The other three keep their phrase tables, and the header says why. Add and remove have to know WHERE the item starts, and a cosine over a whole utterance does not say which byte the milk begins at. Clear deletes the list, so a false claim loses rows he cannot get back — that is not the trade a margin makes. Measured on TestONNXTopics, four held-out cases added: 27/27, no case regressed. Two existing margins moved by under a hundredth because the new seeds became the runner-up, both still far clear of topicMargin.
139 lines
6.2 KiB
Go
139 lines
6.2 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},
|
||
}
|
||
|
||
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))
|
||
}
|