diff --git a/cmd/mavend/actions_list.go b/cmd/mavend/actions_list.go index e7e7d61..7ee2723 100644 --- a/cmd/mavend/actions_list.go +++ b/cmd/mavend/actions_list.go @@ -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) diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go index 8d8aea7..0dd0cb5 100644 --- a/cmd/mavend/topics.go +++ b/cmd/mavend/topics.go @@ -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 diff --git a/cmd/mavend/topics_test.go b/cmd/mavend/topics_test.go index 3558475..76cb99c 100644 --- a/cmd/mavend/topics_test.go +++ b/cmd/mavend/topics_test.go @@ -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}} diff --git a/internal/router/list.go b/internal/router/list.go index 40ad637..b2e1960 100644 --- a/internal/router/list.go +++ b/internal/router/list.go @@ -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))