voice/routing: fix time-query misroute (seed collision, threshold, stage-0 grammars)

three bugs causing time queries to land on reminder or fact intent:

- seed collision: query.txt and system.txt shared identical time/date
  seeds (который час, сколько времени), making system intent
  indistinguishable from query intent in centroid space
- threshold (0.35) too low for ONNX embedder — cosine similarities
  cluster 0.5-0.7 for related intents, so Clarify never fired
- reminder centroid contaminated by time-lexicon (every seed has a time
  expression), pulling any time-word utterance toward reminder intent

fixes:
- remove 3 duplicate time/date seeds from query.txt (keep in system.txt)
- DefaultRouterThreshold 0.35 -> 0.55
- stage-0 grammar for напомни/remind me -> IntentReminder, bypasses
  classifier (fixes 'напомни через час' being misrouted to fact)
- stage-0 grammars for time/date system queries (сколько времени,
  который час, какой сегодня день) -> IntentSystem, with Build filter
  to exclude elapsed/duration queries (сколько времени прошло)
- time parser fallback in applyAction for stage-0 reminder matches
  (extractor doesn't run on stage-0 decisions)
This commit is contained in:
kami
2026-07-06 18:23:13 +04:00
parent fe2903e878
commit ce3d8e65f2
4 changed files with 186 additions and 4 deletions
+23 -3
View File
@@ -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{},
+1 -1
View File
@@ -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
)
+99
View File
@@ -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
}
+63
View File
@@ -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