From f7e1187823320957c69a359503b4ad3ea9d68f29 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 01:02:10 +0400 Subject: [PATCH] Match quiet-mode toggles on whole words, and resolve OFF first --- cmd/mavend/quiet_toggle_test.go | 114 +++++++++++++++++++++++++++++ cmd/mavend/voice.go | 125 +++++++++++++++++++++++++++----- 2 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 cmd/mavend/quiet_toggle_test.go diff --git a/cmd/mavend/quiet_toggle_test.go b/cmd/mavend/quiet_toggle_test.go new file mode 100644 index 0000000..b5c1280 --- /dev/null +++ b/cmd/mavend/quiet_toggle_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// quietFakeAPI records the WriteFact the toggle performs. +type quietFakeAPI struct { + ipc.UnimplementedCoreAPI + got ipc.WriteFactReq + call int +} + +func (a *quietFakeAPI) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) { + a.got, a.call = req, a.call+1 + return 1, nil +} + +// quietVerdict — what a phrase should do to the setting. +type quietVerdict int + +const ( + quietNone quietVerdict = iota + quietOn + quietOff +) + +func TestResolveQuietToggle(t *testing.T) { + cases := []struct { + text string + want quietVerdict + }{ + // ON vocabulary. + {"quiet on", quietOn}, + {"quiet mode", quietOn}, + {"тихий режим", quietOn}, + {"тихий", quietOn}, + {"не шуми", quietOn}, + {"не беспокоить", quietOn}, + {"тихо", quietOn}, + // ON, inflected / embedded in a sentence. + {"включи тихий режим", quietOn}, + {"побудь в тихом режиме", quietOn}, + {"Тихий Режим!", quietOn}, + {"тихая", quietOn}, + + // OFF vocabulary — all seven, incl. the three that used to say ON. + {"quiet off", quietOff}, + {"quiet end", quietOff}, + {"громкий режим", quietOff}, + {"шумный режим", quietOff}, + {"отмени тихий", quietOff}, + {"выключи тихий", quietOff}, + {"не тихо", quietOff}, + // OFF wins over the ON words it contains. + {"выключи тихий режим", quietOff}, + {"отмени тихий режим пожалуйста", quietOff}, + {"верни громкий режим", quietOff}, + + // False positives: "тихо"/"тихий" as ordinary Russian. + {"очень тихий сегодня день", quietNone}, + {"в комнате тихо", quietNone}, + {"тихонько напомни", quietNone}, + {"потихоньку", quietNone}, + {"тихонько", quietNone}, + {"он говорил тихим голосом весь вечер", quietNone}, + + // Unrelated. + {"напомни завтра позвонить маме", quietNone}, + {"какая погода", quietNone}, + {"", quietNone}, + } + + for _, tc := range cases { + t.Run(tc.text, func(t *testing.T) { + api := &quietFakeAPI{} + h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }} + reply, handled := h.resolveQuietToggle(context.Background(), tc.text) + + if tc.want == quietNone { + if handled || reply != "" { + t.Fatalf("%q: got (%q, %v), want no match", tc.text, reply, handled) + } + if api.call != 0 { + t.Fatalf("%q: wrote a fact on a non-match", tc.text) + } + return + } + if !handled { + t.Fatalf("%q: not handled, want %v", tc.text, tc.want) + } + wantReply, wantVal := "тихий режим выключен.", "false" + if tc.want == quietOn { + wantReply, wantVal = "тихий режим включён. буду реже напоминать.", "true" + } + if reply != wantReply { + t.Errorf("%q: reply = %q, want %q", tc.text, reply, wantReply) + } + if api.call != 1 { + t.Fatalf("%q: WriteFact called %d times, want 1", tc.text, api.call) + } + if api.got.Kind != "config" || api.got.Key != "quiet_hours" || api.got.Source != "tap:voice" || api.got.Confidence != 1.0 { + t.Errorf("%q: request shape = %+v", tc.text, api.got) + } + if api.got.Value != wantVal { + t.Errorf("%q: value = %q, want %q", tc.text, api.got.Value, wantVal) + } + }) + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 53c849f..2a14732 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -50,6 +50,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/dialogue" @@ -323,27 +324,12 @@ func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when // the utterance is a quiet-on/off command; ("", false) otherwise. Called from -// HandlePushToTalk BEFORE the router so a classifier miscue can't drop it. +// 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. func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) { - u := strings.ToLower(strings.TrimSpace(text)) - var on, off bool - // Match as whole-token phrases so "тихий" in "тихий режим включи" still - // catches, but "тихий" alone in "очень тихий сегодня день" doesn't fire. - // The confirm turn is handled above, so "да"/"нет" won't reach here. - for _, kw := range []string{"quiet on", "quiet mode", "тихий режим", "тихий", "не шуми", "не беспокоить", "тихо"} { - if strings.Contains(u, kw) { - on = true - break - } - } - if !on { - for _, kw := range []string{"quiet off", "quiet end", "громкий режим", "шумный режим", "отмени тихий", "выключи тихий", "не тихо"} { - if strings.Contains(u, kw) { - off = true - break - } - } - } + on, off := classifyQuietToggle(text) if !on && !off { return "", false } @@ -367,6 +353,105 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) ( return reply, true } +// quietInflections — the inflectional endings a stem may carry and still be +// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is +// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from +// "тихонько"/"потихоньку", which are different words: "онько" is not an +// ending, and "потихоньку" doesn't start with the stem at all. +var quietInflections = []string{ + "", "а", "е", "и", "й", "о", "у", "ы", "ю", "я", + "ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья", + "ами", "ого", "ому", "ыми", "ать", "ить", "ять", +} + +// quietStem reports whether tok is the given stem carrying at most one +// inflectional ending. 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. +func quietStem(tok, stem string) bool { + if !strings.HasPrefix(tok, stem) { + return false + } + suffix := tok[len(stem):] + for _, e := range quietInflections { + if suffix == e { + 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 stem sequences. +var ( + quietOffPhrases = [][]string{ + {"quiet", "off"}, {"quiet", "end"}, + {"громк", "режим"}, {"шумн", "режим"}, + {"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"}, + } + quietOnPhrases = [][]string{ + {"quiet", "on"}, {"quiet", "mode"}, + {"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"}, + {"тих"}, + } +) + +// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is +// resolved before ON for the same reason classifyConfirm checks negatives +// first: the OFF phrases are built out of the ON words ("выключи тихий" +// contains "тихий"), so scanning ON first would shadow them and "выключи +// тихий режим" would turn quiet mode on. Negation wins. +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) { + return true, false + } + } + return false, false +} + // replySystem answers system-observable queries using the handler's clock // and (in future) system interfaces. The decision's utterance is parsed // for keywords to determine what the user is asking about.