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:
+23
-3
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user