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{},