278eeeffdf
Two recognisers claimed world questions naming a day, both by the same mechanism and neither by its keyword floor. topics: weather was the only topic whose seeds carry a day word, four of eight. So every "какой сегодня X" landed nearest it. "какой сегодня курс доллара" cleared the margin by 0.0220 and "какой сегодня праздник" by 0.0398, against 0.0883 for a real weather question, and the gate asked "для какого города?" about the dollar. The margin was not the knob: 0.0398 is not a coin flip, and raising the bar far enough would take real weather with it. topicOther was missing the negative class. Six seeds, four naming a day and two carrying the "какой сегодня X" frame itself — a frame both topics use has to sit on both sides, or the side that owns it wins every noun it has never seen. personal boundary: the same shape one layer down. "что у меня сегодня" and "когда моя встреча" put "when does a thing happen" on the personal side and no world seed answered it, so "во сколько закат сегодня" was refused as his. Three world seeds, each carrying сегодня, which is the half of the frame that does the pulling — without it they caught nothing. Measured, both opt-in against the ONNX embedder homesrv runs: TestONNXTopics 27/27 -> 34/34 (7 new cases, none regressed) TestONNXPersonalBoundary 19/19 -> 22/22 (3 new cases, none regressed) The control matters as much as the fix: "во сколько у меня встреча" is the same frame about something that IS his, and it holds at +0.0842, unchanged from before the seeds moved.
154 lines
7.4 KiB
Go
154 lines
7.4 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},
|
||
}
|
||
|
||
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))
|
||
}
|