Files
Maven/cmd/mavend/topics.go
T
claude 982c25118a topic seeds get a query vector to score (V-547)
turnIsAbout scored t.vec, and t.vec was set in one place: queryEmbed, the
source at actions_query.go:125. Every topic source sits above it — attention,
list, feeds, home, network, weather. So best was handed an empty slice on every
deployed turn, returned ok=false, and all six recognisers ran on their keyword
floors. The seeds have decided nothing outside the tests since the mechanism
landed.

TestONNXTopics passes because it embeds each utterance itself and calls best
directly. That is the shape that hid this for a month: it measures the scorer
and never the wiring. Found on the box instead — "что мне нужно купить" was
answered from an old note about a monitor, and the seeds place it as the list by
0.0841.

turnVector computes the vector on first ask and caches it on the turn;
queryEmbed returns early when it is already set. Chosen over moving the embed
source up the list, because the cost is then paid only by turns that ask a
topic source, and the order of querySources keeps meaning what its comments
argue for.

One scenario assertion moved, and it is a behaviour change rather than a bent
test. morning_missed step 5 pinned "не знаю" for "что я пропустил?" with an
unresolved Praxis item on the board. isAttentionQuery does not match that
phrasing and the topicAttend seeds carry "что важное я пропустил" almost
verbatim, so she reads the item back now. Reading a surfaced item aloud is not
inventing a morning summary, so the floor that step exists for still holds;
what moved is which source answers.
2026-08-05 18:51:42 +04:00

307 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log"
"sync"
"github.com/kami/maven/internal/router"
)
// Which subject is this question about — the weather, the house, the LAN, what
// needs looking at, his feeds, or none of them. Third of the three mechanisms replacing hand-written Russian
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
// sets that can be finished and internal/morph answers the grammar questions;
// this is for the sets that can never be finished, because "is this about the
// house" is a question about meaning and no word list closes it.
//
// The recognisers this replaces were each built the same way: a stem list,
// an ask test, a device-noun list, and a bail-out list for the neighbouring
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
// and "какая температура в доме" share their only content word. isNetworkQuery
// matched "сети" as a whole token because the substring lives inside "посетил",
// so "сколько машин я посетил" read as a request to scan the LAN. Those are not
// bugs in the lists, they are the lists being asked to do semantics.
//
// So the seeds decide, the same way the personal boundary does
// (personalboundary.go), against the same embedder and the same query vector the
// turn already carries. One difference in the gate, and it is deliberate. The
// boundary claims on the sign of the difference, because there a false claim
// costs one honest "не знаю". Here a false claim runs a network scan, or names a
// capability as off on a box where it is simply not the subject — so a topic has
// to win by a margin, and the losing side of a thin call falls through to the
// next query source, which is what the narrow regexes were achieving.
//
// A fourth subject joined on the same day: isAttentionQuery, "что требует
// внимания", which is Praxis's operational state and reached the web search
// before the source existed (Vikunja #475).
//
// A fifth joined on 05-08-2026: the feeds, "что нового в лентах". Its word lists
// were the last pair of hand-written Russian stem lists in the router (V-522),
// and they carried the same admission in their own comments — vagueNouns exists
// because "что нового?" is a greeting that matched a feed noun.
//
// The regexes stay as the offline floor, unchanged, for a handler with no
// embedder or a turn whose vector never got computed. They are allowed to remain
// narrow now precisely because they are no longer the only answer.
// topicLabel — the subjects worth telling apart, plus the one that means none of
// them. topicOther is a real class and not a threshold: a question needs
// somewhere to lose TO, and "интернет не работает" losing to a set that contains
// complaints is a better statement than it failing a number.
type topicLabel string
const (
topicWeather topicLabel = "weather"
topicHome topicLabel = "home"
topicNetwork topicLabel = "network"
topicAttend topicLabel = "attention"
topicFeed topicLabel = "feeds"
topicList topicLabel = "list"
topicOther topicLabel = "other"
)
// topicMargin — how far a topic must clear the runner-up. Small, because the
// margins between neighbouring topics are small: measured on held-out
// utterances, a true weather question clears the home set by roughly 0.02 to
// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the
// asymmetry named above — this gate spends a scan, so a coin-flip falls
// through rather than acts.
const topicMargin = 0.01
// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a
// recogniser and has to be re-measured against TestONNXTopics, not eyeballed.
//
// Each set covers the phrasings its old regex covered, INCLUDING the ones it
// needed a bail-out list for: the weather set carries "какая температура на
// улице" and the home set "какая температура в доме", so the pair that forced
// isHomeQuery to exclude weather words by hand is now just two seeds sitting on
// their own sides.
var topicSeedSets = map[topicLabel][]string{
topicWeather: {
"какая сегодня погода",
"какая температура на улице",
"будет дождь сегодня",
"на улице холодно",
"прогноз погоды на завтра",
"сколько градусов сейчас",
"what is the weather like",
"is it going to rain today",
},
topicHome: {
"что включено в доме",
"какая температура в доме",
"свет в квартире горит",
"сколько лампочек включено дома",
"что у меня дома с датчиками",
"умный дом что сейчас работает",
"розетки в доме включены",
"what is on in the house",
},
topicNetwork: {
"какие устройства в сети",
"кто в сети сейчас",
"просканируй локальную сеть",
"сколько машин в сетке",
"покажи хосты в сети",
"какие адреса заняты в локальной сети",
"кто подключён к вайфаю",
"what devices are on the network",
},
topicAttend: {
"что требует внимания",
"что не так сейчас",
"на что мне посмотреть",
"что важное я пропустил",
"есть что-то срочное",
"что там висит нерешённое",
// With a thing named. Every other seed asks in the abstract, and the
// attention question he actually asks names his services or his
// projects.
"что не так с сервисами",
"что там с моими проектами",
"what needs attention",
"what needs looking at right now",
},
topicFeed: {
"что нового в лентах",
"какие новости",
"что нового по технологиям",
"почитай заголовки",
// Two seeds carrying a day word beside the headlines. Without them
// "какие сегодня заголовки" read as weather, because "какая сегодня
// погода" is the nearest thing in the whole set with "сегодня" in it.
"заголовки за сегодня",
"какие главные новости за день",
"покажи новости за сегодня",
"что пишут в новостях",
"что нового про политику",
"расскажи что нового в ленте",
"what is new in the feeds",
"any news headlines today",
},
// Reading a standing list back, and only that. Adding to one and clearing
// one stay on the phrase tables in internal/router/list.go — see its header
// for why a span and a delete are not seed-shaped work.
topicList: {
"что в списке покупок",
"что мне нужно купить",
"прочитай список покупок",
"покажи что в списке",
"что осталось купить в магазине",
"что мне нужно в аптеке",
"какой у меня список покупок",
"what is on my shopping list",
"read me the grocery list",
},
topicOther: {
// A task question is not a list read-back. They collide on "что у меня",
// and the list has its own table to lose to as well.
"какие у меня задачи",
"что у меня в делах",
// The bare newness opener, which is a greeting and not a request for
// headlines. It sits here on purpose: it is close enough to the feed
// seeds that it will not clear topicMargin, and a thin call goes to
// ParseFeedQuery, which declines a vague noun with no topic beside it.
"что нового",
"как дела",
// Complaints, which are not requests to scan or to read the house.
// isNetworkQuery's comment names this one: a scan she runs unasked is
// the noisy behaviour the bounds exist to prevent.
"интернет не работает",
"вайфай тормозит",
"свет погас",
// Statements. "я дома" was the reason isHomeQuery needed an ask test.
"я дома",
"я уже дома",
// The collision that made "сети" a whole-token match.
"сколько машин я посетил",
"сколько домов мы посмотрели",
// Ordinary questions, his and the world's, so a topic has something
// real to lose to rather than an arbitrary floor.
"почему небо синее",
"какая столица франции",
"что я говорил про бэкапы",
"что у меня сегодня по календарю",
"напомни мне позвонить маме",
// An attention question is about the state of his things; this is not.
"что ты умеешь",
"what did i say about backups",
},
}
// topicIndex holds the embedded seeds. Zero value is usable and means "not
// loaded yet"; a handler built without an embedder never loads and every caller
// uses its own floor instead.
type topicIndex struct {
once sync.Once
vecs map[topicLabel][][]float32
loaded bool
}
// load embeds every set once per process, on the QUERY side — a question
// compared with a question, for the reason personalBoundary.load gives.
func (x *topicIndex) load(ctx context.Context, emb router.Embedder) {
x.once.Do(func() {
if emb == nil {
return
}
vecs := make(map[topicLabel][][]float32, len(topicSeedSets))
for label, seeds := range topicSeedSets {
out := make([][]float32, 0, len(seeds))
for _, s := range seeds {
v, err := router.EmbedQuery(ctx, emb, s)
if err != nil {
log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err)
return
}
out = append(out, v)
}
vecs[label] = out
}
x.vecs, x.loaded = vecs, true
})
}
// best returns the nearest label, how far it cleared the runner-up, and whether
// the seeds answered at all. ok is false when they are not loaded, which is the
// caller's signal to use its floor.
func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) {
if !x.loaded || len(vec) == 0 {
return "", 0, false
}
first, second := -1.0, -1.0
for l, seeds := range x.vecs {
top := -1.0
for _, s := range seeds {
if c := cosine(vec, s); c > top {
top = c
}
}
switch {
case top > first:
label, first, second = l, top, first
case top > second:
second = top
}
}
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.
//
// A topic that wins WITHOUT the margin is handed to the floor rather than
// claimed or dropped, and the near-miss is logged. That is the cascade shape
// again: the better test leads, the offline one always answers, and a thin call
// is exactly where the cheap high-precision test earns its place. Measured, the
// one held-out case that lands there is "вайфай опять отвалился", which reads as
// 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(h.turnVector(ctx, t))
if !ok {
return floor(t.dec.Utterance)
}
if label != want {
return false
}
if margin < topicMargin {
claimed := floor(t.dec.Utterance)
log.Printf("voice: %q reads as %s by only %.4f; the keyword floor says %v", t.dec.Utterance, want, margin, claimed)
return claimed
}
return true
}