28a940ebbe
- 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.
178 lines
7.0 KiB
Go
178 lines
7.0 KiB
Go
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
|
||
}
|