package router import "strings" // Feed queries — "что нового в лентах?", "что нового по технологиям?" // (Vikunja #258). // // 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. type FeedQuery struct { Category string } // feedNouns — the words that name the feeds themselves. One of these is enough, // with an ask, to make the turn a feed question. var feedNouns = []string{ "лента", "ленте", "ленты", "лентах", "лентам", "новости", "новостей", "новостях", "новостям", "feed", "feeds", "news", "headlines", } // vagueNouns — the newness words that are NOT about the feeds by themselves. // // "что нового?" is the most common opener in the language and it is a greeting, // not a request for headlines. It used to match here, so the shipping daemon — // which has no feeds block — answered "я пока не читаю ленты, они не настроены", // a configuration status in reply to hello. With feeds on it answered "в лентах // пока ничего нового", which is no better. A vague noun claims the turn only // when the utterance narrows it: a named topic ("что нового по технологиям"), or // a feed noun somewhere in it ("что нового в лентах"). var vagueNouns = []string{"новое", "нового", "новенького", "new"} // newnessMarkers — the "что нового" half. "нового" alone is in feedNouns // because it carries the question on its own ("что нового?"); a bare "лента" // needs the ask, which is what askMarkers below is for. var askMarkers = []string{ "что", "какие", "какое", "расскажи", "почитай", "прочитай", "покажи", "what", "any", "tell", "show", "read", } // ParseFeedQuery reports whether an utterance asks what is new in the feeds, and // which topic if it names one after "по"/"об"/"про"/"about". // // A feed noun and an ask are required. "у меня новая лента в инстаграме" is a // statement and must not be read as a request to recite headlines. A vague // newness word counts as the noun only when a topic is named — see vagueNouns // for why the bare "что нового?" must fall through. func ParseFeedQuery(text string) (FeedQuery, bool) { toks := planTokens(text) noun, vague, ask := false, false, false for _, t := range toks { for _, n := range feedNouns { if t == n { noun = true break } } for _, n := range vagueNouns { if t == n { vague = true break } } for _, a := range askMarkers { if t == a { ask = true break } } } if !ask { return FeedQuery{}, false } cat := feedCategory(toks) if !noun && !(vague && cat != "") { return FeedQuery{}, false } return FeedQuery{Category: cat}, true } // categoryPreps — the prepositions a topic follows. Russian marks the topic with // a preposition ("по технологиям", "про политику"), so the word after one is the // category; there is no stemming here, and the match against the configured // category is a prefix comparison for exactly that reason. // // "о" is not in the list. It is one rune and it turns up as filler, a typo and // half of "о'кей", so any utterance carrying a stray "о" produced a category of // whatever word came next and she answered "по этой теме в лентах пока ничего" // to a question that named no theme. "об" and "про" carry the same meaning and // 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) { next := toks[i+1] // "по новостям" names no topic, it repeats the noun. for _, n := range append(append([]string{}, feedNouns...), vagueNouns...) { if next == n { return "" } } return next } } return "" } // CategoryMatches reports whether a feed note's own category tag is the one he // named. Russian inflects the topic ("технологиям" vs the configured // "технологии"), and there is no stemmer in this repo, so the comparison is on a // common prefix — long enough that "полит" and "погод" stay apart, short enough // to survive a case ending. // // tag is the note's stored category (rss.NoteCategory), NOT the whole note. It // used to be the whole note, which meant "что нового про погоду" matched any // tech headline whose link happened to contain "pogod". func CategoryMatches(tag, category string) bool { if category == "" { return true } stem := categoryStem(category) if stem == "" { return false } return strings.Contains(strings.ToLower(tag), stem) } // categoryStem cuts a word down to the part inflection leaves alone. 5 runes is // the compromise: shorter words are used whole. func categoryStem(word string) string { r := []rune(strings.ToLower(strings.TrimSpace(word))) if len(r) > 5 { r = r[:5] } return string(r) }