b2521988e1
The daemon tests that compared against one literal ask the entry instead: IsAck names the line she could have said without pinning the wording. The eval scores every ack variant on the persona checks the nudges already pass.
221 lines
8.6 KiB
Go
221 lines
8.6 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"
|
||
"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
|
||
}
|
||
|
||
// 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"},
|
||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||
// 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 stem 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. 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 {
|
||
for _, stem := range quietWordStems {
|
||
if quietStem(t, stem) {
|
||
return false, true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false, false
|
||
}
|