26d6d71588
The querySources order predates the 2026-08-02 ruling that live search leads. An unconfigured feeds source claimed every news question and answered with a configuration status, so "что происходит сейчас в новостях про искусственный интеллект?" never reached the search sitting one source below. It now claims only when neither SearXNG nor the ZIMs are configured, which is the case the "не читаю ленты" line was written for — general knowledge would otherwise invent a bulletin. The calendar matches on a day word alone and sits above the weather, so "какая сегодня погода в Москве?" answered "на 02.08.2026 ничего нет." It now steps aside on weather wording, the same bail-out queryHome already does. "что нового в лентах?" routed system and answered "пока не умею", while the same question worded with "новостях" worked. FeedQueryGrammar routes it to query at stage 0, requiring an ask word and a feed noun so the bare greeting "что нового?" stays a greeting. Wired in the eval too, since the fixture is only worth anything while its grammar set is the daemon's. Also: the claiming source is now logged. /trace is the nudge-rule trace and carries no query-source field, so a wrong answer could not be told apart from a wrongly-ordered chain. Kiwix having no live coverage is filed separately as V-508 — it is a decision about search quality, not an ordering fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
13 KiB
Go
289 lines
13 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,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
},
|
||
{
|
||
// 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,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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 ("в
|
||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||
// than a fixed ending.
|
||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||
|
||
// 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
|
||
}
|