Files
Maven/internal/router/stage0.go
T
kami 28a940ebbe feat: LLM router with chat intent and Cyrillic wake-word support
- Add LLMRouter: grammar-constrained LFM call for intent classification
  after stage-0, before classifier cascade. Errors fall through gracefully.
- Add IntentChat: conversational intent with no store side-effect, routed
  through LLM -> phraser chat endpoint.
- Extract slots for Chat: no structured slots, full utterance is payload.
- Extend stage-0 grammars to fire through Cyrillic wake-word spellings
  (Мэйвен/Мейвен/Майвен/etc.) produced by Russian STT model.
- StripWakeToken helper strips leading wake in any script so time/date
  grammars still match when wake is present.
- Add classifier examples for chat utterances (EN + RU).
- Wire LLMRouter into Router.Config; optional, nil-safe.
2026-07-10 15:48:48 +04:00

178 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}