ba1d8e3f44
The pre-route toggle knew "тихий режим" and every negation of it, but not
the two phrasings that get spoken most: "включи режим тишины" (the setting
named as a noun) and "сделай потише". Both fell through to the router,
which has no quiet intent, so the command did nothing at all.
Adds those as stem pairs, plus a quietWordStems list so the
negated-but-unmatched fallback recognises "хватит тишины" the way it
already recognised "хватит тихого режима".
Locks the English phrasings from the routing fixture in the test table
("turn quiet mode back on", "turn off quiet mode", "stop quiet mode") —
all three already behaved, none were covered.
"на улице стало потише" and "в тишине лучше думается" stay inert: a
single-word pattern still only matches a single-word utterance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
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},
|
|
// The noun form and the comparative.
|
|
{"включи режим тишины", quietOn},
|
|
{"режим тишины", quietOn},
|
|
{"сделай потише", quietOn},
|
|
{"сделай тише", quietOn},
|
|
{"потише", quietOn},
|
|
// English, as the fixture phrases it.
|
|
{"turn quiet mode back on", quietOn},
|
|
{"enable quiet mode", 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},
|
|
{"выключи режим тишины", quietOff},
|
|
{"хватит тишины", quietOff},
|
|
{"turn off quiet mode", quietOff},
|
|
{"quiet mode off", quietOff},
|
|
{"stop quiet mode", quietOff},
|
|
{"disable quiet mode", quietOff},
|
|
|
|
// False positives: "тихо"/"тихий" as ordinary Russian.
|
|
{"очень тихий сегодня день", quietNone},
|
|
{"в комнате тихо", quietNone},
|
|
{"тихонько напомни", 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, sourceVoice)
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
|
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
|
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
|
// turned it on. Negation is scanned over the whole utterance now.
|
|
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
|
off := []string{
|
|
"не надо тихий режим",
|
|
"не хочу тихий режим",
|
|
"тихий режим выключи",
|
|
"убери тихий режим",
|
|
"хватит тихого режима",
|
|
"прекрати тихий режим",
|
|
"тихий режим отмени пожалуйста",
|
|
}
|
|
for _, text := range off {
|
|
t.Run(text, func(t *testing.T) {
|
|
on, isOff := classifyQuietToggle(text)
|
|
if on || !isOff {
|
|
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The two ON phrases that are themselves built on "не" must stay ON: they
|
|
// are requests FOR quiet, not negations of one.
|
|
for _, text := range []string{"не шуми", "не беспокоить"} {
|
|
t.Run(text, func(t *testing.T) {
|
|
on, isOff := classifyQuietToggle(text)
|
|
if !on || isOff {
|
|
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
|
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
|
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
|
// it and the provenance column lied about a daemon-wide setting.
|
|
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
|
for _, src := range []turnSource{sourceVoice, sourceText} {
|
|
t.Run(string(src), func(t *testing.T) {
|
|
api := &quietFakeAPI{}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
|
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
|
t.Fatal("expected the toggle to match")
|
|
}
|
|
if api.got.Source != string(src) {
|
|
t.Errorf("source = %q, want %q", api.got.Source, src)
|
|
}
|
|
})
|
|
}
|
|
}
|