diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index f2898ea..5fd4b46 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -229,6 +229,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem memStore: memStore, dialogueSessions: dialogueSessions, queryMinScore: cfg.Voice.QueryMinScore, + timeParser: router.StubDateTimeParser{}, } // ----- the server (TCP listener) ----- @@ -268,6 +269,11 @@ type reactiveHandler struct { // wireVoice from VoiceConfig; default 0.55. queryMinScore float64 + // timeParser — used as a fallback for stage-0 reminder grammar matches + // (where the extractor didn't run). Shared with the router's extractor. + // The production dateparser will replace StubDateTimeParser here too. + timeParser router.DateTimeParser + // dialogueSessions carries slots across turns for follow-ups (single-user // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore @@ -443,7 +449,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) case router.IntentReminder: if !dec.Slots.HasTime { - return "не получилось разобрать время напоминания." + // Stage-0 (reminder-wakeword grammar) skips the extractor, so the + // time wasn't parsed. Run the parser as a fallback. + if dec.Stage == 0 && h.timeParser != nil { + t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) + if err == nil && ok { + dec.Slots.Time = t + dec.Slots.HasTime = true + } + } + if !dec.Slots.HasTime { + return "не получилось разобрать время напоминания." + } } payload := `{"text":` + jsonString(dec.Utterance) + `}` if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { @@ -745,12 +762,15 @@ func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (v // - 6 bootstrap examples covering the 5 intents + one compound-capture // placeholder. Spec calls for ~10 per intent at production; this is the // bootstrapping floor swapped by tuning the seed set later. -// - Threshold is from voice.router_threshold config (default 0.35). +// - Threshold is from voice.router_threshold config (default 0.55). func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64) *router.Router { cls := router.NewClassifier(emb) seedClassifier(cls) + grammars := router.DefaultGrammars(acts) + grammars = append(grammars, router.SystemTimeDateGrammars()...) + grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ - Grammars: router.DefaultGrammars(acts), + Grammars: grammars, Classifier: cls, Extractor: router.Extractor{ Time: router.StubDateTimeParser{}, diff --git a/internal/config/config.go b/internal/config/config.go index 73d9a08..0a5f5d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -320,7 +320,7 @@ const ( DefaultTickInterval = 60 * time.Second DefaultRepeatInterval = 5 * time.Minute DefaultAutotuneInterval = 10 * time.Minute - DefaultRouterThreshold = 0.35 + DefaultRouterThreshold = 0.55 DefaultQueryMinScore = 0.55 DefaultToolTimeout = 30 * time.Second ) diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 39b95aa..b6113b0 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -53,3 +53,102 @@ func DefaultGrammars(actMatcher ActMatcher) []Grammar { }, } } + +// --- напомни / 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 +} diff --git a/models/seeds/query.txt b/models/seeds/query.txt new file mode 100644 index 0000000..357fc2f --- /dev/null +++ b/models/seeds/query.txt @@ -0,0 +1,63 @@ +что у меня сегодня по календарю +сколько я спал сегодня +когда последний раз поливал цветы +сколько воды я выпил сегодня +какие напоминания на сегодня +что там с бэкапами +сколько времени прошло с последней тренировки +how many hours did I sleep this week +какой сегодня вес +покажи заметки про сервер +какая погода на улице +что нового в логах +как дела у сервера +что произошло за ночь +сколько человек дома +кто сейчас дома +какая температура в комнате +сколько электричества мы потратили +какой баланс на счету +когда был последний бэкап +сколько свободного места на диске +какая версия софта +когда обновлялся сервер +кто заходил в систему +покажи последние события +какой завтра прогноз погоды +сколько времени до встречи +проверь статус всех сервисов +когда кормил кота в последний раз +покажи мои заметки за неделю +какие у меня планы на завтра +сколько дней до отпуска +какая загрузка процессора +сколько оперативной памяти свободно +покажи логи за сегодня +кто последний раз делал бэкап +какой ip адрес у сервера +сколько стоит свет в этом месяценайди заметку про сервер +найди мою заметку о бэкапах +поищи заметку про роутер +найди заметку где я записал пароль +что я записывал про полив +покажи заметку про починку крана +найди в заметках про home assistant +find my note about the database backup +search my notes for the wifi password +what did I note about the garden +что у меня сегодня по плану +какие планы на завтра +что у меня завтра +расписание на сегодня +что сегодня в календаре +покажи календарь на сегодня +планы на сегодня +есть ли что-то завтра +какая погода +какая погода в москве +сколько градусов +температура на улице +холодно сегодня +будет дождь +погода на сегодня +weather in london