package router import ( "regexp" "strings" ) // 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, }, } } // agendaQueryBuild — shared Build for the agenda grammars. 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 }