4757ff6d7b
resolveQuietToggle runs inside runTurn, so mavweb /api/chat and telegram reach it as well as the microphone. Every toggle was written with Source "tap:voice" regardless, which left the facts table claiming a mic flipped a setting nobody spoke to. This is the one function whose own doc comment calls it a network-reachable way to change a daemon-wide setting, and provenance is the first column read when asking why quiet mode is on. runTurn now takes the channel it was entered from and the toggle writes it: "tap:voice" from HandlePushToTalk, "tap:text" from handleText. Found in review of #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
207 lines
7.8 KiB
Go
207 lines
7.8 KiB
Go
// 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.
|
||
//
|
||
// 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 := "тихий режим выключен."
|
||
if on {
|
||
val = "true"
|
||
reply = "тихий режим включён. буду реже напоминать."
|
||
}
|
||
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 "не получилось переключить тихий режим.", 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.
|
||
//
|
||
// 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"},
|
||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||
{"тих"},
|
||
}
|
||
)
|
||
|
||
// 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. Matched through quietStem, the
|
||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||
// and "выключай" all count and "выключатель" does not.
|
||
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 {
|
||
if quietStem(t, "тих") {
|
||
return false, true
|
||
}
|
||
}
|
||
}
|
||
return false, false
|
||
}
|