// Quiet-mode toggle recognition — the pre-route keyword check that lets // "тихий режим" flip the daemon-wide quiet_hours config without going through // the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in // quiet_toggle_test.go. package main import ( "context" "log" "strings" "unicode" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/morph" "github.com/kami/maven/internal/phraser" ) // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when // the utterance is a quiet-on/off command; ("", false) otherwise. Called from // runTurn BEFORE the router so a classifier miscue can't drop it — which means // both the voice path and the text path (mavweb /api/chat, telegram) reach it, // so a false positive here is a network-reachable way to flip a daemon-wide // setting. See classifyQuietToggle for the matching rule. // // src is the channel the utterance arrived on, and it is written straight into // the fact. Every toggle used to be stored as "tap:voice", including the ones // typed into the web UI, which left the facts table claiming a microphone flipped // a setting nobody spoke to. This is the one function where that matters most: // when he goes looking at why quiet mode is on, provenance is the first column // he reads. func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) { on, off := classifyQuietToggle(text) if !on && !off { return "", false } val := "false" reply := phraser.Ack(phraser.AckQuietOff, nil) if on { val = "true" reply = phraser.Ack(phraser.AckQuietOn, nil) } if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: h.now(), Kind: "config", Key: "quiet_hours", Value: val, Source: string(src), Confidence: 1.0, }); err != nil { log.Printf("voice: write quiet_hours: %v", err) return phraser.Ack(phraser.FailQuiet, nil), true } return reply, true } // quietStem reports whether tok is one of the words a vocabulary slot accepts. // A slot is written as alternatives joined by "|", and an alternative comes in // two flavours: // // - a dictionary form, matched through the dictionary, so every case and // gender of it counts. This is what the nouns and adjectives want: "тихий", // "тихом", "тихо" and "тише" are one word. // - a form prefixed with "=", matched as the exact token. This is what the // VERBS want, and it is not a shortcut. A command is an imperative, and the // dictionary quite correctly files "говори" and "говорил" under one lemma — // so lemma-matching a verb slot read "он говорил тихим голосом весь вечер", // a remark about his evening, as an order to go quiet. Aspect pairs are two // separate verbs, which is why several imperatives are listed by hand. // // Word boundaries come from tokenisation (see quietTokens), not from a regexp — // Go's \b is ASCII-oriented and treats every Cyrillic letter as a non-word // character, so `\bтих\b` would happily match inside "тихонько". Comparing whole // tokens sidesteps that entirely. // // The comparison is a dictionary lookup, not a stem plus a list of 36 endings // (Vikunja #526). The distinction the old comment described is exactly the one a // dictionary makes: "тихий", "тихом", "тихо" and "тише" are one word inflected, // while "тихонько" and "потихоньку" are different words — and the dictionary // knows that without anybody deciding that "онько" is not an ending. func quietStem(tok, slot string) bool { for _, form := range strings.Split(slot, "|") { if exact, ok := strings.CutPrefix(form, "="); ok { if tok == exact { return true } continue } if morph.SameWord(tok, form) { return true } } return false } // quietTokens splits an utterance into lowercase word tokens, dropping // punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same // way ASCII ones do. func quietTokens(text string) []string { return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) } // quietPhrase matches a pattern (a sequence of stems) against the token list. // Multi-word patterns match any contiguous run of tokens — "включи тихий // режим" carries "тихий режим". Single-word patterns match ONLY when they are // the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a // remark about the room and must not flip a daemon-wide setting. func quietPhrase(tokens, pattern []string) bool { if len(pattern) == 0 || len(tokens) < len(pattern) { return false } if len(pattern) == 1 { return len(tokens) == 1 && quietStem(tokens[0], pattern[0]) } for i := 0; i+len(pattern) <= len(tokens); i++ { hit := true for j, stem := range pattern { if !quietStem(tokens[i+j], stem) { hit = false break } } if hit { return true } } return false } // quietOffPhrases / quietOnPhrases — the toggle vocabulary, as sequences of // dictionary forms. They used to be truncated stems ("тих", "выключ"), which is // what the ending list existed to complete; a dictionary form needs no // completing (Vikunja #526). // // Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and // the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns, // and negation is not an adjacency phenomenon. "не надо тихий режим" put two // tokens between "не" and "тих", so the OFF pattern missed, the ON pattern // {"тих","режим"} matched, and asking for quiet mode to stop turned it on. // Negation is handled by quietNegators below, over the whole utterance. var ( quietOffPhrases = [][]string{ {"quiet", "off"}, {"quiet", "end"}, {"громкий", "режим"}, {"шумный", "режим"}, {"=отмени|=отменяй|=отменить", "тихий"}, {"=выключи|=выключай|=выключить", "тихий"}, } quietOnPhrases = [][]string{ {"quiet", "on"}, {"quiet", "mode"}, {"тихий", "режим"}, {"не", "=шуми|=шумите"}, {"не", "=беспокой|=беспокоить"}, // The noun form and the comparative. "режим тишины" is how the // setting is named half the time, and "сделай потише" is how it is // actually asked for out loud. Both used to fall through to the // router, which has no quiet intent, so the command did nothing. {"режим", "тишина"}, {"=сделай", "тихий"}, {"=сделай", "потише"}, {"=говори", "тихий"}, {"=будь", "потише"}, {"тихий"}, {"потише"}, } ) // quietWordStems — every word that names the setting. Used by the // negated-but-unmatched fallback in classifyQuietToggle, which has to // recognise "хватит тишины" without an ON phrase having matched. var quietWordStems = []string{"тихий", "тишина", "потише"} // quietNegatorWords — negators that are whole words with no useful stem. var quietNegatorWords = map[string]bool{ "не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true, } // quietNegatorStems — negators that inflect. Imperatives, matched exactly for // the reason quietStem gives: "выключи" is a command and "выключил" is a report // about earlier, and one lemma covers both. "выключатель" was never a negator // and is not one now. var quietNegatorStems = []string{ "=выключи|=выключай|=выключить", "=отмени|=отменяй|=отменить", "=прекрати|=прекращай|=прекратить", "=убери|=убирай|=убрать", "stop", "cancel", "disable", } // quietNegated reports whether the utterance carries a negator. Two ON phrases // are themselves built on "не" — "не шуми", "не беспокой" — and those are // requests FOR quiet, so they are excluded before the scan: a negator only // counts when it is not part of the phrase that matched. func quietNegated(tokens []string, matched []string) bool { if len(matched) > 0 && matched[0] == "не" { return false } for _, t := range tokens { if quietNegatorWords[t] { return true } for _, stem := range quietNegatorStems { if quietStem(t, stem) { return true } } } return false } // classifyQuietToggle reads an utterance as a quiet-mode command. // // Explicit OFF phrases resolve first, for the same reason classifyConfirm // checks negatives first: they are built out of the ON words ("выключи тихий" // contains "тихий"), so scanning ON first would shadow them. An ON phrase that // matches is then checked for negation across the whole utterance, so any way // of saying "not quiet mode" turns it off rather than on. func classifyQuietToggle(text string) (on, off bool) { tokens := quietTokens(text) for _, p := range quietOffPhrases { if quietPhrase(tokens, p) { return false, true } } for _, p := range quietOnPhrases { if quietPhrase(tokens, p) { if quietNegated(tokens, p) { return false, true } return true, false } } // No ON phrase matched, but he negated a quiet word: "не тихо", "хватит // тихого режима". The ON vocabulary cannot see these — bare "тих" only // matches a one-token utterance, by design, so the negator pushes the token // count past it — and reading them as "no command" would leave quiet mode // on after he asked for it to stop. if quietNegated(tokens, nil) { for _, t := range tokens { for _, stem := range quietWordStems { if quietStem(t, stem) { return false, true } } } } return false, false }