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, }, } } // 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 }