package router import ( "regexp" "strings" "unicode" "github.com/kami/maven/internal/lexicon" ) // Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar // hits the allowlist directly, skips the classifier (lowest latency — the vosk // command path). Boring high-frequency acts for free. // // A Grammar returns a fully-formed Decision (intent + slots) at confidence 1.0 // when its pattern matches AND its Build returns ok=true; the router stops the // cascade. Grammar rules are code, not config — same boundary as rules-as-code // in the loop. The tool registry populates the verb set at daemon wiring time. type Grammar struct { Name string Pattern *regexp.Regexp // matched against the raw utterance Build func(match []string) (Decision, bool) } // wakeWordAct — "maven, restart nginx" / "maven restart nginx" → the remainder // is matched against the act allowlist. A non-match returns ok=false so the // cascade falls through to the classifier (a wakeword prefix alone doesn't // guarantee a known command — "maven, i'm tired" is a fact, not an act). var wakeWordAct = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]+(.+)$`) // wakeToken matches a leading wake-word token in any script the STT commonly // produces for "Maven" — Latin "maven" or a Cyrillic phonetic rendering. The // STT is a Russian model, so it transcribes the spoken wake word phonetically // almost every time; matching only the Latin spelling meant stage-0 grammars // (time/date/reminder) silently missed nearly every wake-worded utterance and // fell through to the classifier, which misroutes time queries into the // reminder intent (dense time-vocab centroid, see SystemTimeDateGrammars). var wakeToken = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]*`) // StripWakeToken removes a leading wake-word token (any script/spelling seen // in wakeToken) and reports whether one was found. func StripWakeToken(u string) (string, bool) { loc := wakeToken.FindStringIndex(u) if loc == nil { return u, false } rest := strings.TrimSpace(u[loc[1]:]) if rest == "" { return u, false } return rest, true } // DefaultGrammars — the wake-word act fast path. The ActMatcher is the same // allowlist stage-2 act extraction uses (single source of truth for the fn // list). Returns nil grammars if no matcher is wired (the daemon always wires // one — the guard is for tests that only exercise the classifier). func DefaultGrammars(actMatcher ActMatcher) []Grammar { if actMatcher == nil { return nil } return []Grammar{ { Name: "wakeword-act", Pattern: wakeWordAct, Build: func(m []string) (Decision, bool) { rest := strings.TrimSpace(m[1]) fn, args, ok := actMatcher.Match(rest) if !ok { return Decision{}, false // fall through to classifier } return Decision{ Stage: 0, Intent: IntentAct, Confidence: 1.0, Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest}, }, true }, }, } } // --- напомни / remind me stage-0 grammar --- // // "напомни через час выпить воды" / "remind me in 30 minutes to water plants" // routes directly to IntentReminder, bypassing the classifier entirely. // Without this grammar the reminder centroid (dense with time-lexicon) pulls // non-reminder time queries toward it, and the verb+action overlap pushes // actual reminders toward fact — a double contamination. Stage 0 fixes both. // // The grammar captures the part after "напомни"/"remind me" into Slots.Text // so the daemon's time parser can extract the fire time from it. The grammar // itself does NOT parse time — that's the extractor's job (stage 2), but // stage 0 skips the extractor. The daemon's applyAction fallback calls the // time parser for stage-0 reminders that arrive without HasTime. func ReminderGrammar() Grammar { return Grammar{ Name: "reminder-wakeword", Pattern: regexp.MustCompile(`(?i)^\s*(?:напомни|remind me)[\s,:]+(.+)$`), Build: func(m []string) (Decision, bool) { rest := strings.TrimSpace(m[1]) if rest == "" { return Decision{}, false } return Decision{ Stage: 0, Intent: IntentReminder, Confidence: 1.0, Slots: Slots{Text: rest}, }, true }, } } // SystemTimeDateGrammars — stage-0 grammars for high-frequency system queries // that replySystem handles deterministically (time, date, day-of-week). // "сколько времени" is seeded in BOTH system.txt and query.txt (a centroid // collision), and the reminder centroid contaminates any utterance with time // vocabulary. These grammars route directly to IntentSystem, skipping the // classifier entirely — the answer is always deterministic. // // The time-query grammar uses a broad pattern (prefix match) with a Build // filter: utterances containing "прошло"/"осталось" or starting with "до" // after the time expression are elapsed/duration queries that belong to the // classifier, not to replySystem's "what time is it" handler. func SystemTimeDateGrammars() []Grammar { return []Grammar{ { Name: "time-query", Pattern: regexp.MustCompile(`(?i)^\s*сколько\s+(сейчас\s+)?времени(.*)$`), Build: timeQueryBuild, }, { Name: "clock-query", Pattern: regexp.MustCompile(`(?i)^\s*который\s+(сейчас\s+)?час(\s+у\s+нас|\s+в\s+\w+)?\s*[?!.]?\s*$`), Build: timeDateBuild, }, { Name: "date-query", Pattern: regexp.MustCompile(`(?i)^\s*(?:какой\s+сегодня\s+(?:день|день\s+недели|число)|какое\s+сегодня\s+число)\s*[?!.]?\s*$`), Build: timeDateBuild, }, } } // AgendaQueryGrammars — stage-0 grammars for "what have I got on" questions, // routed to IntentQuery so they reach the query chain (queryDayPlan, // queryCalendar) instead of replySystem. // // This exists because the model puts them in IntentSystem. Measured on the // deployed daemon 01-08-2026: "что у меня сегодня" and "что у меня в календаре // сегодня" both routed system, and replySystem has no agenda arm, so both // answered "пока не умею". The fixture has said query since ru-query-019 was // written ("the clock/date system rule must not swallow it"); the daemon // disagreed with the fixture and the daemon was wrong. // // Routing, not answering. These set the intent and nothing else — which source // in the query chain claims the turn stays the chain's decision, and a // question with no date still falls through queryCalendar to recall. // // Deliberately not folded into SystemTimeDateGrammars: those exist to send // utterances TO system, these exist to keep utterances OUT of it, and one // function returning both would read as a list of clock rules. func AgendaQueryGrammars() []Grammar { return []Grammar{ { // An explicit calendar noun is unambiguous wherever it appears: // "что в календаре на завтра", "покажи расписание на среду". Name: "calendar-query", Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`), Build: agendaQueryBuild, }, { // The agenda phrasing with no calendar noun. Anchored at the start // and requiring the possessive, so it reads as a question about his // day: "что у меня сегодня", "что у меня стоит на послезавтра". // "у меня кончилась вода" is a fact and does not match. Name: "agenda-query", // (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only, so it does // not see a boundary after a Cyrillic letter and the pattern // silently never fires. // "во сколько у меня встреча" is the same agenda question with a // clock word in front, and the clock word is what sent it to // system (fixture ru-query-013). Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`), Build: agendaQueryBuild, }, { // A plan noun aimed at a named day, with no possessive to anchor // on: "какие планы на завтра", "что по делам в среду". The rule // above wants "у меня" and this phrasing never has it, so // "какие планы на завтра" answered "пока не умею" while "какие // планы на сегодня" worked (Vikunja #471). The day word is what // makes it an agenda question rather than a topic. Name: "plan-day-query", // Only "план" and "дел". A verb stem like "встреч" would take // "встречаемся в среду", which is him telling her something, not // asking. Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`), Build: agendaQueryBuild, }, { // "что дальше?" — the rest of the day, with no possessive and no // plan word for the rules above to anchor on, so neither claimed // it and the model called it a fact (Vikunja #498). The predicate // for the same utterance already exists as IsRestOfDayQuery, one // layer down in the query chain; this is what gets the turn there. // // "и что там дальше" and "что потом дальше" are the same question, // and "what's next" splits into two tokens, hence the optional // middles rather than plain adjacency. Name: "rest-of-day-query", Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`), Build: agendaQueryBuild, }, { // A named event with no calendar word at all: "когда планёрка?", // "во сколько созвон". He is asking when something on his calendar // happens, and the noun is the only signal. Closed list, so "когда // битва при Ватерлоо" is still a world question. Name: "event-time-query", Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`), Build: agendaQueryBuild, }, } } // chatNarrativeTopics — the things "расскажи X" asks for that are not // questions about the world. She is being asked to entertain or to describe // herself, and the query chain has no source for either. var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`) // NarrativeQueryGrammars — the stage-0 grammar for "расскажи про X", a question // shape that carries no question mark and no interrogative, and so reached the // resident model with nothing deterministic in front of it (Vikunja #498). // // The model routed it IntentFact. The fact gate catches the write and re-runs // the turn as a query, so nothing broke; what it cost is a full model round trip // to reach a decision one pattern makes offline, and a wrong row on the routing // fixture. // // It held a second grammar named rest-of-day-query until V-530. fe489df merged // task/467 into the sweep line and both sides had landed V-498, so the merge // kept both blocks textually. buildRouter wires the agenda grammars first and // the agenda copy claims every case this one did, so it could never fire. // // Wired after the agenda grammars, which is where the overlap resolves: // "расскажи, что у меня сегодня" is claimed there as a query either way. func NarrativeQueryGrammars() []Grammar { return []Grammar{ { // "расскажи про X" — a world question phrased as an instruction. // The lexicon is narrativeRequests, already written for the // question-shaped test in question.go. // // Anchored at the start: "запиши что мне рассказали" is a capture, // and a narrative verb buried mid-utterance is not the shape. Name: "narrative-query", Pattern: narrativeQueryPattern, Build: narrativeQueryBuild, }, } } // entertainmentNouns — what "расскажи" asks for when it is not asking for // knowledge. "расскажи анекдот про программистов" is chat: he wants her to make // something up, which is the one case where inventing is the right answer // (fixture ru-chat-003). var entertainmentNouns = []string{ "анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки", "joke", "jokes", "story", } // narrativeQueryBuild — the narrative shape is a query carrying its topic, // unless he also said one of the capture verbs, asked for entertainment, or // asked about her. "расскажи и запиши" is him asking for a note, and stage 0 // must not take either off the cascade. // // The topic goes into Slots.Text rather than the whole utterance: the query // chain looks things up by it, and "расскажи мне про Ватерлоо" is a question // about Ватерлоо. func narrativeQueryBuild(m []string) (Decision, bool) { topic := strings.TrimSpace(m[2]) // "расскажи" with nothing after it is a conversational opener, and there is // no topic to look up. if topic == "" { return Decision{}, false } // Against the whole utterance, not the topic: "о себе" has its preposition // eaten by the pattern, leaving a bare "себе". if chatNarrativeTopics.MatchString(m[0]) { return Decision{}, false } for _, t := range planTokens(topic) { for _, v := range captureVerbs { if t == v { return Decision{}, false } } for _, v := range entertainmentNouns { if t == v { return Decision{}, false } } } return Decision{ Stage: 0, Intent: IntentQuery, Confidence: 1.0, Slots: Slots{Text: topic}, }, true } // narrativeQueryPattern — "расскажи про X", built from the lexicon rather than // spelled out here (Vikunja #527). The verbs used to be a second copy of // lexicon.NarrativeRequests, and a second copy of a closed set is a set that // drifts: adding "поясни" in the data file left this rule not knowing it. // // Anchored at the start, which was the point of the old literal and still is: // "запиши что мне рассказали" is a capture, and a narrative verb buried // mid-utterance is not the shape. The dative and the preposition are eaten so // the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves // "Ватерлоо". var narrativeQueryPattern = regexp.MustCompile( `(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`) // FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to // IntentQuery so it reaches queryFeeds. // // Same shape of defect as the agenda grammars: the model calls it system, and // replySystem has no feeds arm, so the documented utterance of task 258 step 1 // answered "пока не умею отвечать на этот вопрос." while the same question // worded with "новостях" worked (Vikunja #474). // // An ask word at the front and a feed noun after it are both required, which // is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting // — the most common opener in the language — and vagueNouns in feeds.go exists // to keep it out of the feed reader; routing it to query here would put it // back. "у меня новая лента в инстаграме" carries the noun without the ask and // stays the statement it is. func FeedQueryGrammar() Grammar { return Grammar{ Name: "feed-query", // (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next // to a Cyrillic letter. Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`), Build: agendaQueryBuild, } } // dayWordPattern — the day words an agenda question can name. Weekdays appear // in the accusative and prepositional forms the questions actually use ("в // среду", "на среде"), so each one contributes its stem plus an inflection // tail; the relative day words are exact. // // Built from the lexicon for the same reason as above. The literal that stood // here spelled all seven weekdays out a second time, in a third file after // cmd/mavend/voice.go and internal/ttsnorm. var dayWordPattern = buildDayWordPattern() // buildDayWordPattern — one alternation over the relative day words, the // weekday stems, and the two period words that are in no closed set ("на // выходных", "на неделе" name a span, not a day). func buildDayWordPattern() string { alts := []string{`выходн[а-я]+`, `недел[а-я]+`} for _, w := range lexicon.DayOffsetWords() { if strings.Contains(w, " ") || !isCyrillic(w) { // Multi-word and English members belong to the offset lookup, not // to a Russian agenda pattern. continue } alts = append(alts, regexp.QuoteMeta(w)) } for i := 0; i < 7; i++ { day := lexicon.Weekday(i) if day == "" { continue } alts = append(alts, weekdayStem(day)+`[а-я]*`) } return `(` + strings.Join(alts, "|") + `)` } // weekdayStem trims the nominative ending off a weekday so the pattern matches // the case forms an agenda question uses: "среда" has to reach "в среду", and // "понедельник" already ends on its stem. func weekdayStem(day string) string { r := []rune(day) switch r[len(r)-1] { case 'а', 'я', 'е', 'о', 'ь': return string(r[:len(r)-1]) } return day } // isCyrillic reports whether every rune is Cyrillic. Used to keep the English // members of a bilingual lexicon set out of a Russian-only pattern. func isCyrillic(s string) bool { for _, r := range s { if !unicode.Is(unicode.Cyrillic, r) { return false } } return s != "" } // agendaQueryBuild — shared Build for the agenda grammars and the feed one, // which all do the same single thing: keep the utterance out of IntentSystem // and let the query chain decide who answers it. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own // matchers decide the rest. func agendaQueryBuild(m []string) (Decision, bool) { return Decision{ Stage: 0, Intent: IntentQuery, Confidence: 1.0, }, true } // timeQueryBuild — Build for the time-query grammar. Returns ok=false for // elapsed/duration queries ("сколько времени прошло", "сколько времени // осталось", "сколько времени до") so they fall through to the classifier. // The classifier handles them as query intent (notes RAG), not system. func timeQueryBuild(m []string) (Decision, bool) { suffix := strings.TrimSpace(m[2]) if suffix != "" && !strings.HasPrefix(suffix, "?") { lower := strings.ToLower(suffix) // If the first word after "времени" is a duration marker, this is an // elapsed-time query, not a "what time is it" query. firstWord := strings.Fields(lower) if len(firstWord) > 0 { switch firstWord[0] { case "прошло", "осталось", "до", "пройдет", "минуло", "проходит": return Decision{}, false } } } return Decision{ Stage: 0, Intent: IntentSystem, Confidence: 1.0, }, true } // timeDateBuild — shared Build for clock-query and date-query grammars. Returns a // Decision routed to IntentSystem with the original utterance intact, so the // daemon's replySystem handler can keyword-match and answer it. func timeDateBuild(m []string) (Decision, bool) { return Decision{ Stage: 0, Intent: IntentSystem, Confidence: 1.0, }, true }