8015fdbb79
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.
528 lines
23 KiB
Go
528 lines
23 KiB
Go
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
|
||
}
|