diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 5ab5895..1577da6 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -468,6 +468,10 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin // sources below both need, run once, in the position it always ran in. It // only claims the turn when the embedder fails. func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) { + // A topic source above already paid for this one; see turnVector. + if len(t.vec) > 0 { + return "", false + } vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) diff --git a/cmd/mavend/testdata/scenarios/morning_missed.json b/cmd/mavend/testdata/scenarios/morning_missed.json index f398039..3302812 100644 --- a/cmd/mavend/testdata/scenarios/morning_missed.json +++ b/cmd/mavend/testdata/scenarios/morning_missed.json @@ -74,9 +74,9 @@ }, { "at": "08:50", - "note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.", + "note": "he asks what he missed, and Praxis holds one unresolved item — the morning medicine — so she reads that back. This step pinned \"не знаю\" until 05-08-2026, and that was the keyword floor's blind spot rather than a rule: isAttentionQuery does not match \"что я пропустил\", while the topicAttend seeds carry \"что важное я пропустил\" almost verbatim. The seeds only started deciding when turnVector fixed the empty query vector every topic source was reading (V-547). Reading a surfaced item aloud is not inventing a morning summary, so the no-hallucination floor still holds; what moved is which source answers. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.", "say": "что я пропустил?", - "expect_reply_contains": ["не знаю"], + "expect_reply_contains": ["требует внимания", "morning_medicine"], "expect_reply_lacks": ["рад ", "милый", "ваш"] }, { diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go index 0dd0cb5..944f19e 100644 --- a/cmd/mavend/topics.go +++ b/cmd/mavend/topics.go @@ -249,6 +249,35 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b return label, first - second, true } +// turnVector returns the turn's query vector, computing it on first ask and +// caching it on the turn. +// +// It exists because every topic source sits ABOVE the "embed" source in +// querySources, and that source was the only thing that ever set t.vec. So +// turnIsAbout was reading an empty vector on every deployed turn, best returned +// ok=false, and all six recognisers ran on their keyword floors — the seeds +// decided nothing outside the tests, which embed the utterance themselves and +// call best directly. Found on the box on 05-08-2026: "что мне нужно купить" was +// answered from an old note, and the seeds place it as the list by 0.0841. +// +// Computing here rather than moving the embed source up: the cost is paid by the +// turns that ask, the cache means queryEmbed below reuses this one, and the +// order of querySources stays what its comments argue for. +func (h *reactiveHandler) turnVector(ctx context.Context, t *queryTurn) []float32 { + if len(t.vec) > 0 || h.recall.embedder == nil { + return t.vec + } + vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance) + if err != nil { + // The floor answers. A topic source is not the place to fail a turn: + // the recall sources below hit the same embedder and report it there. + log.Printf("voice: topic vector for %q: %v", t.dec.Utterance, err) + return nil + } + t.vec = vec + return vec +} + // turnIsAbout — the recogniser every topic source calls. The seeds decide when // the embedder is there, which is every deployed box; floor is the source's own // keyword test, which answers when they are not. @@ -261,7 +290,7 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b // network by 0.0055; isNetworkQuery says no, so it stays the complaint it is. func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool { h.recall.topics.load(ctx, h.recall.embedder) - label, margin, ok := h.recall.topics.best(t.vec) + label, margin, ok := h.recall.topics.best(h.turnVector(ctx, t)) if !ok { return floor(t.dec.Utterance) }