diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 03fdda9..5ab5895 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -292,6 +292,14 @@ const ( feedReadOut = 3 ) +// feedFloor — the keyword test behind topicFeed, in the one-string shape +// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the +// gate has no use for; the caller reads it separately. +func feedFloor(u string) bool { + _, ok := router.ParseFeedQuery(u) + return ok +} + // queryFeeds — "что нового в лентах?", "что нового по технологиям?" // (Vikunja #258). // @@ -299,10 +307,13 @@ const ( // never speaks; asking is the trigger. If that ever changes, the thing that // changed is "Maven is not a nag", not a detail of this file. func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) { - q, ok := router.ParseFeedQuery(t.dec.Utterance) - if !ok { + // The seeds decide the subject; router.ParseFeedQuery is the floor behind + // them (V-522). The category still comes from the utterance either way, + // because a topic is marked by a preposition and needs no recogniser. + if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) { return "", false } + category := router.FeedCategoryOf(t.dec.Utterance) if !h.feedsOn { // Claim only when nothing below can read the world. The reason this // source used to claim unconditionally was that general knowledge would @@ -327,7 +338,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, } var picked []string for _, n := range notes { - if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) { + if !router.CategoryMatches(rss.NoteCategory(n.Text), category) { continue } // The note carries title, summary, category tag and link; she reads the @@ -339,7 +350,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, } } if len(picked) == 0 { - if q.Category != "" { + if category != "" { return phraser.Q(phraser.QueryFeedsTopic, nil), true } return phraser.Q(phraser.QueryFeedsEmpty, nil), true diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go index 5f72744..8d8aea7 100644 --- a/cmd/mavend/topics.go +++ b/cmd/mavend/topics.go @@ -9,7 +9,7 @@ import ( ) // Which subject is this question about — the weather, the house, the LAN, what -// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian +// 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 @@ -37,6 +37,11 @@ import ( // внимания", 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. @@ -52,6 +57,7 @@ const ( topicHome topicLabel = "home" topicNetwork topicLabel = "network" topicAttend topicLabel = "attention" + topicFeed topicLabel = "feeds" topicOther topicLabel = "other" ) @@ -117,7 +123,30 @@ var topicSeedSets = map[topicLabel][]string{ "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", + }, topicOther: { + // 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. diff --git a/cmd/mavend/topics_test.go b/cmd/mavend/topics_test.go index 0cc72a1..3558475 100644 --- a/cmd/mavend/topics_test.go +++ b/cmd/mavend/topics_test.go @@ -25,6 +25,7 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { {"что включено в доме?", topicHome, isHomeQuery, true}, {"какие устройства в сети?", topicNetwork, isNetworkQuery, true}, {"что требует внимания?", topicAttend, isAttentionQuery, true}, + {"что нового в лентах?", topicFeed, feedFloor, true}, {"почему небо синее", topicWeather, isWeatherQuery, false}, {"я дома", topicHome, isHomeQuery, false}, {"интернет не работает", topicNetwork, isNetworkQuery, false}, @@ -86,6 +87,12 @@ func TestONNXTopics(t *testing.T) { {"что требует моего внимания сейчас", topicAttend, isAttentionQuery}, {"что не так с базой данных", topicAttend, isAttentionQuery}, {"есть что-то срочное на сегодня", topicAttend, isAttentionQuery}, + {"что нового в ленте за сегодня", topicFeed, feedFloor}, + {"какие сегодня заголовки", topicFeed, feedFloor}, + {"что нового про искусственный интеллект", topicFeed, feedFloor}, + // 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}, } h := &reactiveHandler{recall: recallWiring{embedder: emb}} diff --git a/internal/router/feeds.go b/internal/router/feeds.go index f88b16e..79b45b6 100644 --- a/internal/router/feeds.go +++ b/internal/router/feeds.go @@ -5,10 +5,16 @@ import "strings" // Feed queries — "что нового в лентах?", "что нового по технологиям?" // (Vikunja #258). // -// Deterministic matching, like the calendar, plan and habit matchers above it: -// the LLM router says this is a query, and this decides whether it is a question -// about the feeds. A model deciding that would occasionally answer "что нового?" -// out of world knowledge, which is the one thing a feed reader exists to avoid. +// This file is the OFFLINE FLOOR as of 05-08-2026 (V-522). Whether a turn is +// about the feeds is a question about meaning, so the frozen seeds decide it — +// topicFeed in cmd/mavend/topics.go, through turnIsAbout. The word lists below +// stay because they always answer: a box with no embedder, a turn whose vector +// never got computed, and any call that does not clear topicMargin. They are +// allowed to stay narrow now that they are not the only answer. +// +// What has not changed is that no generative model decides this. One would +// occasionally answer "что нового?" out of world knowledge, which is the one +// thing a feed reader exists to avoid. // FeedQuery — a parsed "what's new" question. Category is the topic he named // ("технологии"), empty when he asked about the feeds in general. @@ -95,6 +101,13 @@ func ParseFeedQuery(text string) (FeedQuery, bool) { // cannot be mistaken for anything else. var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true} +// FeedCategoryOf reads the topic out of an utterance without deciding whether the +// turn is a feed question at all. The seeds answer that now (topicFeed in +// cmd/mavend/topics.go, V-522), and they answer it for phrasings the word lists +// here never held — but a claimed turn still needs its category, and a category +// is marked by a preposition rather than recognised. +func FeedCategoryOf(text string) string { return feedCategory(planTokens(text)) } + func feedCategory(toks []string) string { for i, t := range toks { if categoryPreps[t] && i+1 < len(toks) {