Files
Maven/internal/router/stage0.go
T
claude 8015fdbb79 Harden semantic boundaries and repair dialogue state
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
2026-08-13 03:00:31 +04:00

528 lines
23 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"
"sort"
"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)
// Decide is the non-regexp form for a grammar whose evidence is structural
// rather than textual. Exactly one of Decide or Pattern+Build is set. It
// keeps token/grammar parsers first-class instead of wrapping them in a
// catch-all regexp merely to fit this type.
Decide func(utterance string) (Decision, bool)
}
// Evaluate applies one grammar. matched distinguishes a regexp whose outer
// shape matched but whose Build declined from a rule that never matched; the
// decision trace uses that distinction. A structural Decide has no weaker
// outer pattern, so accepted is also its matched result.
func (g Grammar) Evaluate(utterance string) (d Decision, matched, accepted bool) {
if g.Decide != nil {
d, accepted = g.Decide(utterance)
return d, accepted, accepted
}
if g.Pattern == nil || g.Build == nil {
return Decision{}, false, false
}
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
return Decision{}, false, false
}
d, accepted = g.Build(m)
return d, true, accepted
}
// 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 —
// what she says at the hour. The grammar itself does NOT parse time; that is
// the extractor's job, and since V-572 the router runs the extractor over a
// stage-0 decision too (fillMatchedSlots in router.go). Before that it did not,
// so "напомни в 11:00 позвонить маме" reached the daemon with HasTime false and
// was asked "Когда?" about an hour he had just said.
//
// The verb alternation is built from lexicon.ReminderVerbs rather than written
// out (V-627). The literal here knew "напомни" and "remind me" and nothing
// else, so "разбуди меня в 6:30" never reached stage 0 — and it does not reach
// IntentReminder further down either, where the classifier calls it fact at
// 0.918. An alarm is a reminder that fires at the hour he gets up, and the
// verb that names one is her vocabulary, so it belongs in the lexicon with the
// rest of it.
//
// Longest-first ordering matters: Go's regexp alternation is leftmost-first,
// not longest-match, so "напомнить" listed after "напомни" would never match.
var reminderVerbPattern = regexp.MustCompile(
`(?i)^\s*(?:` + longestFirstAlternation(lexicon.ReminderVerbs()) +
`)\s*(?:мне|меня|me)?[\s,:]+(.+)$`)
// longestFirstAlternation joins a word set into a regexp alternation, longest
// alternative first, with every member escaped.
func longestFirstAlternation(set []string) string {
out := make([]string, 0, len(set))
for _, w := range set {
out = append(out, regexp.QuoteMeta(w))
}
sort.Slice(out, func(i, j int) bool {
if len(out[i]) != len(out[j]) {
return len(out[i]) > len(out[j])
}
return out[i] < out[j]
})
return strings.Join(out, "|")
}
func ReminderGrammar() Grammar {
return Grammar{
Name: "reminder-wakeword",
Pattern: reminderVerbPattern,
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. Two of the five also name the calendar as the
// destination (V-655), which narrows who may GUESS their way onto the turn and
// claims nothing. Every source that looks something up still runs, in the order
// it always did, so 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:
// "что в календаре на завтра", "покажи расписание на среду".
//
// The one agenda rule that names its destination, because an
// explicit calendar noun leaves nothing to weigh (V-655). The
// possessive rules below deliberately do not: "что у меня в списке
// покупок" matches agenda-query, and naming the calendar there
// would take the list source off the turn.
Name: "calendar-query",
Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`),
Build: queryTo(SourceCalendar),
},
{
// 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.
// Names the calendar (V-655): the noun list is closed and every
// member of it is an event, so there is nothing else to weigh.
Name: "event-time-query",
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
Build: queryTo(SourceCalendar),
},
}
}
// 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
}
toks := planTokens(topic)
for _, v := range captureVerbs {
if hasTok(toks, v) {
return Decision{}, false
}
}
for _, v := range entertainmentNouns {
if hasTok(toks, v) {
return Decision{}, false
}
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
// The world, because that is the shape this asks for and the rule has
// already declined the two cases where it is not: entertainment, and
// questions about her (V-655). His own notes are still read first — a
// destination narrows who may guess and reorders nothing.
Source: SourceWorld,
}, 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
}