Merge the list read-back seeds (#179)

This commit is contained in:
2026-08-05 18:43:19 +04:00
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))