package main import ( "context" "os" "path/filepath" "testing" "github.com/kami/maven/internal/router" ) // A handler with no embedder never loads the seeds, so the boundary falls back // to the possession markers. That is the offline floor and it must keep working // — an embedder that fails to load must not open the boundary. func TestBoundaryFallsBackToMarkersWithNoEmbedder(t *testing.T) { h := personalHandler() if !h.isPersonalTurn(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "во сколько у меня встреча"}, }) { t.Error("no embedder: a possession question must still be personal") } if h.isPersonalTurn(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "почему небо синее"}, }) { t.Error("no embedder: a world question must still pass") } } // TestONNXPersonalBoundary — the number that matters, scored against the // embedder homesrv actually runs. Opt-in via MAVEN_ONNX_LIB, exactly like // TestONNXRecall in internal/memory/recalleval. // // Every case here is held out: none of these strings is a seed. The #495 // regression is the first row — "что я говорил про бэкапы?" reached SearXNG and // was answered from a Habr article, and no possession word appears in it. func TestONNXPersonalBoundary(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 personal bool }{ {"что я говорил про бэкапы?", true}, {"что я сказал вчера про отпуск", true}, {"я писал что-нибудь про сервер", true}, {"я упоминал про конференцию?", true}, {"что я отмечал по поводу переезда", true}, {"я рассказывал тебе про новую работу?", true}, {"во сколько у меня встреча", true}, {"когда мой следующий отпуск", true}, {"what did i say about backups", true}, {"did i tell you about the doctor", true}, {"как я говорил, почему небо синее", false}, {"как уже я говорил, какая столица франции", false}, {"почему трава зелёная", false}, {"столица франции", false}, {"как мне сварить борщ", false}, {"что мне посмотреть вечером", false}, {"я хочу узнать про рим", false}, {"кто такой гагарин", false}, {"how do i boil an egg", false}, // Asking when a public thing happens (Vikunja #553). "во сколько закат // сегодня" was answered "не знаю — не нашла у тебя такой записи", // because the frame lived only on the personal side. The pair above it // is the control: "во сколько у меня встреча" is the same frame about // something that IS his, and it has to stay personal. {"во сколько закат сегодня", false}, {"когда сегодня заканчивается концерт", false}, {"во сколько завтра открывается аптека", false}, // The "какой сегодня X" frame. These clear the weather topic after the // V-553 seeds and were then refused here, which is the same defect one // source further down the chain. {"какой сегодня праздник", false}, {"что интересного произошло сегодня в мире", false}, {"кто выиграл вчера матч", false}, // The narrative shape, held out from the seeds above (Vikunja #554). // The control is the row after them: the same verb about his own words // is still his. {"расскажи про эверест", false}, {"расскажи про войну 1812 года", false}, {"объясни что такое инфляция", false}, {"я рассказывал тебе про байкал?", true}, } h := &reactiveHandler{recall: recallWiring{embedder: emb}} ctx := context.Background() wrong := 0 for _, c := range cases { vec, err := router.EmbedQuery(ctx, emb, c.utterance) if err != nil { t.Fatalf("embed %q: %v", c.utterance, err) } turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec} got := h.isPersonalTurn(ctx, turn) p, w, ok := h.recall.boundary.score(vec) if !ok { t.Fatal("seeds did not load with a working embedder") } if got != c.personal { wrong++ t.Errorf("%q: personal=%v want %v (personal %.4f world %.4f)", c.utterance, got, c.personal, p, w) } t.Logf("personal=%-5v personal %.4f world %.4f delta %+.4f %s", got, p, w, p-w, c.utterance) } t.Logf("personal boundary: %d/%d held-out utterances correct", len(cases)-wrong, len(cases)) }