list read-back asks the seeds; add and clear keep their tables (V-522)

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.
This commit is contained in:
2026-08-05 18:43:13 +04:00
parent 3e87ad7eb6
commit a08403087f
4 changed files with 73 additions and 2 deletions
+13 -2
View File
@@ -95,6 +95,12 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
return "", false
}
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
func listFloor(u string) bool {
_, ok := router.ParseListQuery(u)
return ok
}
// queryList — "что в списке покупок?", "что мне купить?".
//
// A query source, so it sits in querySources and either claims the turn or
@@ -102,10 +108,15 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
// source is: the notes pass would otherwise answer a list question with
// whatever note is nearest.
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
list, ok := router.ParseListQuery(t.dec.Utterance)
if !ok || h.dataStore == nil {
if h.dataStore == nil {
return "", false
}
// The seeds decide the subject and listQueryPrefixes is the floor behind
// them (V-522). Which list he named is a noun lookup either way.
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
return "", false
}
list := router.ListNamedIn(t.dec.Utterance)
items, err := h.dataStore.ListItems(ctx, list, "")
if err != nil {
log.Printf("voice: list items: %v", err)
+19
View File
@@ -58,6 +58,7 @@ const (
topicNetwork topicLabel = "network"
topicAttend topicLabel = "attention"
topicFeed topicLabel = "feeds"
topicList topicLabel = "list"
topicOther topicLabel = "other"
)
@@ -140,7 +141,25 @@ var topicSeedSets = map[topicLabel][]string{
"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
+7
View File
@@ -26,6 +26,7 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
{"что требует внимания?", topicAttend, isAttentionQuery, true},
{"что нового в лентах?", topicFeed, feedFloor, true},
{"что в списке покупок?", topicList, listFloor, true},
{"почему небо синее", topicWeather, isWeatherQuery, false},
{"я дома", topicHome, isHomeQuery, false},
{"интернет не работает", topicNetwork, isNetworkQuery, false},
@@ -93,6 +94,12 @@ func TestONNXTopics(t *testing.T) {
// 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}}
+34
View File
@@ -17,6 +17,15 @@ import (
// The markers are deliberately explicit. "молоко закончилось" is an
// observation about the world and belongs in a note; only an instruction to
// put something on a list puts it there.
//
// The four paths split on 05-08-2026, and the split is by what the caller needs
// rather than by language (V-522). Reading a list back needs one bit — is this
// about the list — so the seeds decide it, topicList through turnIsAbout, and
// listQueryPrefixes below is the offline floor. The other three keep the tables
// as the answer. Add and remove need 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 it stays on exact phrases: a false claim there
// loses rows he cannot get back, which is not the trade a margin makes.
// listTags — the lists he can name, as one dictionary form each. Russian
// declines the tag ("список покупок", "в покупки", "в покупках"), and the
@@ -186,6 +195,31 @@ func ParseListQuery(text string) (string, bool) {
return list, true
}
// ListNamedIn reports which standing list an utterance names, anywhere in it,
// defaulting to покупки when it names none.
//
// takeListTag is not enough for a read-back, because it reads the FRONT of a
// remainder a prefix table has already eaten. The seeds claim a read-back
// without eating anything (topicList, cmd/mavend/topics.go, V-522), so "что мне
// нужно в аптеке" has to be scanned rather than trimmed. A list name is a noun
// in the dictionary, so this is a lookup and decides nothing about meaning.
func ListNamedIn(text string) string {
for _, f := range strings.Fields(strings.ToLower(text)) {
head := strings.Trim(f, listTrimCut)
for _, s := range listTags {
if morph.SameWord(head, s.word) {
return s.list
}
}
for _, s := range listTagsEN {
if head == s.word {
return s.list
}
}
}
return "покупки"
}
// ParseListClear reports whether an utterance crosses off a whole list.
func ParseListClear(text string) (string, bool) {
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))