From c5317eb2b4d9eeda5ee62f6b36ebcde6443086ec Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 01:30:06 +0400 Subject: [PATCH] Move the quiet-toggle and pattern-extraction slices out of voice.go (#321) Continues the decomposition PR #50 started. voice.go 542 -> 365: quiet_toggle.go 144 resolveQuietToggle, quietInflections, quietStem, quietTokens, quietPhrase, quietOn/OffPhrases, classifyQuietToggle (quiet_toggle_test.go already existed for these) patterns.go +44 detectPattern, next to detectAndPropose which it calls and which patterns.go's own header already pointed at What is left in voice.go is the handler: reactiveHandler, HandlePushToTalk, handleText, runTurn, applyAction, replySystem, chatHistory, reply. Move-only: all 133 distinct non-blank lines removed from voice.go were matched in the two destination files, zero lines added to voice.go. The only non-move edits are import lists (log added to patterns.go, unicode and internal/pattern dropped from voice.go) and two comments that pointed at voice.go for code that is no longer there. --- cmd/mavend/patterns.go | 48 +++++++++- cmd/mavend/quiet_toggle.go | 144 ++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 177 ------------------------------------- 3 files changed, 191 insertions(+), 178 deletions(-) create mode 100644 cmd/mavend/quiet_toggle.go diff --git a/cmd/mavend/patterns.go b/cmd/mavend/patterns.go index ab90fed..7fd5381 100644 --- a/cmd/mavend/patterns.go +++ b/cmd/mavend/patterns.go @@ -1,6 +1,6 @@ // mavend/patterns.go — the shared detect+propose step of pattern inference // (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact- -// write time in voice.go's detectPattern, tied to whichever channel wrote the +// write time in detectPattern below, tied to whichever channel wrote the // fact. Detection — turning a run of events into a proposed routine — is // channel-agnostic: it only needs what's already in the events table, so it // runs both right after a voice fact-write (for the immediate "напоминать?" @@ -13,6 +13,7 @@ import ( "context" "errors" "fmt" + "log" "time" "github.com/kami/maven/internal/pattern" @@ -72,3 +73,48 @@ func detectAndPropose(ctx context.Context, ds *store.Store, action, object strin } return r, id, nil } + +// detectPattern extracts an event from the written fact and runs the pattern +// detector. If a stable recurring pattern is found and no proposed routine +// exists for this action+object yet, one is created and the user is prompted +// to confirm via the park() mechanism. Returns the suggestion phrase when a +// new proposal was created and parked; "" otherwise. +func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string { + ev := pattern.Extract(factID, key, value, ts) + if ev == nil { + return "" // not an actionable event + } + if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil { + log.Printf("voice: create event: %v", err) + return "" + } + // Detect+propose (Vikunja #43) is shared with the digestion tick's + // proactive scan — see detectAndPropose above. Event *extraction* stays + // here, tied to this fact write; detection over the accumulated history does + // not need to happen right now for the voice path to have already done + // its job — it's dedupe-safe to also let the next tick find the same + // pattern independently. + r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts) + if err != nil { + log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err) + return "" + } + if r == nil { + return "" // not enough data, too irregular, or already proposed/decided + } + log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) + + // Park the proposal for voice confirmation. + phrase := pattern.PhraseRoutine(r) + h.mu.Lock() + h.pendingRoutine = &pendingRoutineConfirm{ + routineID: id, + action: r.Action, + object: r.Object, + interval: r.IntervalDays, + phrase: phrase, + expiry: ts.Add(confirmTTL), + } + h.mu.Unlock() + return phrase +} diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go new file mode 100644 index 0000000..e4bf9c3 --- /dev/null +++ b/cmd/mavend/quiet_toggle.go @@ -0,0 +1,144 @@ +// 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" +) + +// 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. +func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) { + on, off := classifyQuietToggle(text) + if !on && !off { + return "", false + } + val := "false" + reply := "тихий режим выключен." + if on { + val = "true" + reply = "тихий режим включён. буду реже напоминать." + } + if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: h.now(), + Kind: "config", + Key: "quiet_hours", + Value: val, + Source: "tap:voice", + Confidence: 1.0, + }); err != nil { + log.Printf("voice: write quiet_hours: %v", err) + return "не получилось переключить тихий режим.", true + } + 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 +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2a14732..79327d0 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -50,13 +50,11 @@ import ( "strings" "sync" "time" - "unicode" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" - "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" @@ -277,181 +275,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return "" } -// detectPattern extracts an event from the written fact and runs the pattern -// detector. If a stable recurring pattern is found and no proposed routine -// exists for this action+object yet, one is created and the user is prompted -// to confirm via the park() mechanism. Returns the suggestion phrase when a -// new proposal was created and parked; "" otherwise. -func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string { - ev := pattern.Extract(factID, key, value, ts) - if ev == nil { - return "" // not an actionable event - } - if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil { - log.Printf("voice: create event: %v", err) - return "" - } - // Detect+propose (Vikunja #43) is shared with the digestion tick's - // proactive scan — see patterns.go. Event *extraction* above stays here, - // tied to this fact write; detection over the accumulated history does - // not need to happen right now for the voice path to have already done - // its job — it's dedupe-safe to also let the next tick find the same - // pattern independently. - r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts) - if err != nil { - log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err) - return "" - } - if r == nil { - return "" // not enough data, too irregular, or already proposed/decided - } - log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) - - // Park the proposal for voice confirmation. - phrase := pattern.PhraseRoutine(r) - h.mu.Lock() - h.pendingRoutine = &pendingRoutineConfirm{ - routineID: id, - action: r.Action, - object: r.Object, - interval: r.IntervalDays, - phrase: phrase, - expiry: ts.Add(confirmTTL), - } - h.mu.Unlock() - return phrase -} - -// 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. -func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) { - on, off := classifyQuietToggle(text) - if !on && !off { - return "", false - } - val := "false" - reply := "тихий режим выключен." - if on { - val = "true" - reply = "тихий режим включён. буду реже напоминать." - } - if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ - Ts: h.now(), - Kind: "config", - Key: "quiet_hours", - Value: val, - Source: "tap:voice", - Confidence: 1.0, - }); err != nil { - log.Printf("voice: write quiet_hours: %v", err) - return "не получилось переключить тихий режим.", true - } - 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.