package router 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. // 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 make a question about the feeds themselves. var feedNouns = []string{ "лента", "ленте", "ленты", "лентах", "лентам", "новости", "новостей", "новостях", "новостям", "новое", "нового", "новенького", "feed", "feeds", "news", "headlines", } // 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". // // Both a feed noun and an ask are required. "у меня новая лента в инстаграме" is // a statement and must not be read as a request to recite headlines. func ParseFeedQuery(text string) (FeedQuery, bool) { toks := planTokens(text) noun, ask := false, false for _, t := range toks { for _, n := range feedNouns { if t == n { noun = true break } } for _, a := range askMarkers { if t == a { ask = true break } } } if !noun || !ask { return FeedQuery{}, false } return FeedQuery{Category: feedCategory(toks)}, 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. var categoryPreps = map[string]bool{"по": true, "о": true, "об": true, "про": true, "about": true, "on": true} 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 feedNouns { if next == n { return "" } } return next } } return "" } // CategoryMatches reports whether a note's text plausibly belongs to the // category 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. func CategoryMatches(text, category string) bool { if category == "" { return true } stem := categoryStem(category) if stem == "" { return false } return strings.Contains(strings.ToLower(text), 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) }