d9ef9ecef2
The textual merge in fe489df left a second rest-of-day-query grammar inside
NarrativeQueryGrammars. buildRouter wires the agenda grammars first, so the
copy never claimed a turn, and narrative_test.go only ever indexed the
narrative rule beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
456 lines
20 KiB
Go
456 lines
20 KiB
Go
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
|
||
}
|