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,
|
memStore: memStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
queryMinScore: cfg.Voice.QueryMinScore,
|
queryMinScore: cfg.Voice.QueryMinScore,
|
||||||
|
timeParser: router.StubDateTimeParser{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the server (TCP listener) -----
|
// ----- the server (TCP listener) -----
|
||||||
@@ -268,6 +269,11 @@ type reactiveHandler struct {
|
|||||||
// wireVoice from VoiceConfig; default 0.55.
|
// wireVoice from VoiceConfig; default 0.55.
|
||||||
queryMinScore float64
|
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
|
// dialogueSessions carries slots across turns for follow-ups (single-user
|
||||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||||
dialogueSessions *dialogue.SessionStore
|
dialogueSessions *dialogue.SessionStore
|
||||||
@@ -443,7 +449,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
|
|
||||||
case router.IntentReminder:
|
case router.IntentReminder:
|
||||||
if !dec.Slots.HasTime {
|
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) + `}`
|
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
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
|
// - 6 bootstrap examples covering the 5 intents + one compound-capture
|
||||||
// placeholder. Spec calls for ~10 per intent at production; this is the
|
// placeholder. Spec calls for ~10 per intent at production; this is the
|
||||||
// bootstrapping floor swapped by tuning the seed set later.
|
// 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 {
|
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64) *router.Router {
|
||||||
cls := router.NewClassifier(emb)
|
cls := router.NewClassifier(emb)
|
||||||
seedClassifier(cls)
|
seedClassifier(cls)
|
||||||
|
grammars := router.DefaultGrammars(acts)
|
||||||
|
grammars = append(grammars, router.SystemTimeDateGrammars()...)
|
||||||
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: router.DefaultGrammars(acts),
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
Extractor: router.Extractor{
|
Extractor: router.Extractor{
|
||||||
Time: router.StubDateTimeParser{},
|
Time: router.StubDateTimeParser{},
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ const (
|
|||||||
DefaultTickInterval = 60 * time.Second
|
DefaultTickInterval = 60 * time.Second
|
||||||
DefaultRepeatInterval = 5 * time.Minute
|
DefaultRepeatInterval = 5 * time.Minute
|
||||||
DefaultAutotuneInterval = 10 * time.Minute
|
DefaultAutotuneInterval = 10 * time.Minute
|
||||||
DefaultRouterThreshold = 0.35
|
DefaultRouterThreshold = 0.55
|
||||||
DefaultQueryMinScore = 0.55
|
DefaultQueryMinScore = 0.55
|
||||||
DefaultToolTimeout = 30 * time.Second
|
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