From 79c3b994cf60ab22ec75a776b3048bc80179af74 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 21:40:06 +0400 Subject: [PATCH] =?UTF-8?q?mavend:=20let=20him=20say=20"=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BC"=20to=20a=20nudge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nudge could only be deferred from Telegram or the web UI. The voice path had no route to store.ResolveNudge at all, so the channel she nudges on hardest was the one he could not answer out loud. resolveSnooze runs pre-route, right after the quiet toggle, and writes the same `snoozed` outcome the buttons write — which also drops the row out of RepeatUnacked, so a deferred sev4 stops re-sending every five minutes. The window is what makes this safe to run before the router. "потом" is an ordinary word; it only counts as a deferral when a pending nudge was sent in the last twenty minutes, and otherwise the turn routes normally. Single word patterns still match single-word utterances only, so "потом схожу за водой" reports a plan instead of silencing the rule that prompted it. Channel is not filtered: a nudge that went to Telegram is still what he is answering when he says "потом" at the microphone. QA-PLAN gains the two new manual checks and drops the 319 warning. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- QA-PLAN.md | 20 ++++--- cmd/mavend/snooze.go | 107 +++++++++++++++++++++++++++++++++++ cmd/mavend/snooze_test.go | 115 ++++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 8 +++ 4 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 cmd/mavend/snooze.go create mode 100644 cmd/mavend/snooze_test.go diff --git a/QA-PLAN.md b/QA-PLAN.md index cea8969..913d853 100644 --- a/QA-PLAN.md +++ b/QA-PLAN.md @@ -47,15 +47,21 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures). 4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. 5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no `quiet_hours` fact was written. -6. Note anything where she is slow, cuts off, or talks over herself. That is +6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode + on. These are the noun form and the comparative, added 01-08-2026. +7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо, + вернусь к этому позже.` and the nudge row on `/notifications` reading + `snoozed`. Say `потом` again with nothing pending: it must route as an + ordinary utterance, not be swallowed. +8. Note anything where she is slow, cuts off, or talks over herself. That is 287's whole content and it has no written acceptance criteria yet. -**Expect one known failure.** Single-word Russian utterances get turned into -`не совсем поняла — можешь переформулировать?` even when routed correctly. I saw -it today: `привет` routes as `intent=chat` and the gate clarifies it anyway. -That is the single-token rule in `gateLLMDecision`, an English intuition that -does not survive contact with Russian. Tracked in **319**. Do not chase it -during the smoke test. +**319 is fixed** (01-08-2026). Single-word Russian utterances no longer come +back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал` +both pass now: `thinSingleToken` spares social singles and any token carrying a +verb ending, and only thins a bare nominal like `вода`. If a one-word utterance +still gets clarified during the smoke test, that is a new case for the lexicon, +not the old bug. --- diff --git a/cmd/mavend/snooze.go b/cmd/mavend/snooze.go new file mode 100644 index 0000000..88fb235 --- /dev/null +++ b/cmd/mavend/snooze.go @@ -0,0 +1,107 @@ +// Spoken snooze — "не сейчас", "потом", "отложи" said out loud after a nudge +// resolves it as `snoozed`, the same outcome the Telegram buttons and the web +// UI write. Until this existed, a nudge could only be deferred by touching a +// screen: the voice path had no way to reach store.ResolveNudge at all, so the +// one channel she nudges on hardest was the one channel he could not answer. +package main + +import ( + "context" + "log" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/store" +) + +// snoozeWindow — how long after a send "потом" still means "that nudge". +// +// A window is what makes this safe to run before the router. "потом" is an +// ordinary Russian word; eating every one of them would break real sentences. +// Bounded to the minutes right after she spoke, the word is almost always an +// answer to what she just said, and outside the window the utterance falls +// through and routes normally. +// +// Twenty minutes rather than the two hours of store.SnoozeDuration: those +// measure different things. SnoozeDuration is how long the quiet lasts, +// snoozeWindow is how long an unanswered nudge stays the topic of the +// conversation. +const snoozeWindow = 20 * time.Minute + +// snoozeScan — how many recent nudges to look at when finding the target. The +// newest pending one is nearly always the first row; a handful of resolved +// rows can sit in front of it when he acked a few in a row. +const snoozeScan = 10 + +// resolveSnooze — pre-route keyword check, run after the quiet toggle. Returns +// (reply, true) when the utterance defers a nudge she recently sent. +// +// It returns ("", false) in two different situations, on purpose: the words do +// not read as a deferral, or they do but there is nothing pending to defer. In +// both the turn keeps routing, so "потом посмотрю что там с бэкапом" is still +// a query when no nudge is outstanding. +func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src turnSource) (string, bool) { + if !classifySnooze(text) { + return "", false + } + now := h.now() + target, ok := h.pendingNudge(ctx, now) + if !ok { + return "", false + } + if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil { + log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) + return "не получилось отложить.", true + } + log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src) + return "хорошо, вернусь к этому позже.", true +} + +// pendingNudge — the newest still-pending nudge sent inside snoozeWindow. +// +// Channel is deliberately not filtered. A nudge that went to Telegram is still +// the thing he is answering when he says "потом" at the microphone, and making +// the reply channel decide which nudges are answerable would mean the ops page +// he actually read could not be dismissed by voice. +func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.Nudge, bool) { + recent, err := h.api.RecentNudges(ctx, snoozeScan) + if err != nil { + log.Printf("voice: recent nudges for snooze: %v", err) + return ipc.Nudge{}, false + } + for _, n := range recent { + if n.Outcome != store.NudgePending { + continue + } + if now.Sub(n.Ts) > snoozeWindow || n.Ts.After(now) { + continue + } + return n, true + } + return ipc.Nudge{}, false +} + +// snoozePhrases — the deferral vocabulary, as stem sequences. Matched by +// quietPhrase (quiet_toggle.go), which carries the rule that matters here: +// a single-word pattern matches only a single-word utterance. Bare "потом" is +// an answer; "потом схожу за водой" is a plan, and reporting a plan must not +// silence the rule that prompted it. +var snoozePhrases = [][]string{ + {"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"}, + {"напомн", "позже"}, {"напомн", "потом"}, {"спрос", "позже"}, + {"отлож"}, {"позже"}, {"потом"}, {"попозже"}, {"погоди"}, + {"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"}, +} + +// classifySnooze reads an utterance as a deferral. Unlike the quiet toggle +// there is no negation arm: "не потом" is not something anyone says, and the +// leading "не" of "не сейчас" is part of the phrase itself. +func classifySnooze(text string) bool { + tokens := quietTokens(text) + for _, p := range snoozePhrases { + if quietPhrase(tokens, p) { + return true + } + } + return false +} diff --git a/cmd/mavend/snooze_test.go b/cmd/mavend/snooze_test.go new file mode 100644 index 0000000..eb34c21 --- /dev/null +++ b/cmd/mavend/snooze_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/store" +) + +// snoozeFakeAPI serves a fixed nudge list and records the resolution. +type snoozeFakeAPI struct { + ipc.UnimplementedCoreAPI + nudges []ipc.Nudge + + gotID int64 + gotOutcome string + calls int +} + +func (a *snoozeFakeAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { + return a.nudges, nil +} + +func (a *snoozeFakeAPI) ResolveNudge(_ context.Context, id int64, outcome string, _ time.Time) error { + a.gotID, a.gotOutcome, a.calls = id, outcome, a.calls+1 + return nil +} + +var snoozeNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + +func snoozeHandler(nudges []ipc.Nudge) (*reactiveHandler, *snoozeFakeAPI) { + api := &snoozeFakeAPI{nudges: nudges} + return &reactiveHandler{api: api, now: func() time.Time { return snoozeNow }}, api +} + +func pendingNudgeAt(id int64, ago time.Duration) ipc.Nudge { + return ipc.Nudge{ID: id, Ts: snoozeNow.Add(-ago), Rule: "water", Channel: "voice", Outcome: store.NudgePending} +} + +func TestClassifySnooze(t *testing.T) { + yes := []string{ + "не сейчас", "потом", "позже", "попозже", "отложи", "погоди", + "напомни позже", "напомни потом", "не могу сейчас", + "not now", "later", "snooze", + } + for _, s := range yes { + if !classifySnooze(s) { + t.Errorf("classifySnooze(%q) = false, want true", s) + } + } + no := []string{ + // A single-word pattern must not eat the sentence it appears in. + "потом схожу за водой", "позже посмотрю что там с бэкапом", + "напомни завтра позвонить маме", "какая погода", "погода на завтра", + "я отложил деньги", "", "тихий режим", + } + for _, s := range no { + if classifySnooze(s) { + t.Errorf("classifySnooze(%q) = true, want false", s) + } + } +} + +func TestResolveSnoozeDefersTheNewestPendingNudge(t *testing.T) { + h, api := snoozeHandler([]ipc.Nudge{ + {ID: 9, Ts: snoozeNow.Add(-time.Minute), Rule: "meal", Outcome: store.NudgeActed}, + pendingNudgeAt(8, 3*time.Minute), + pendingNudgeAt(7, 10*time.Minute), + }) + reply, handled := h.resolveSnooze(context.Background(), "не сейчас", sourceVoice) + if !handled || reply == "" { + t.Fatalf("got (%q, %v), want a reply", reply, handled) + } + if api.gotID != 8 || api.gotOutcome != store.NudgeSnoozed { + t.Fatalf("resolved (%d, %q), want (8, %q)", api.gotID, api.gotOutcome, store.NudgeSnoozed) + } +} + +func TestResolveSnoozeFallsThroughWithNothingPending(t *testing.T) { + // The whole point of the window: with no live nudge, "потом" is just a + // word and must keep routing. + for _, name := range []string{"stale", "resolved", "empty"} { + var nudges []ipc.Nudge + switch name { + case "stale": + nudges = []ipc.Nudge{pendingNudgeAt(3, snoozeWindow+time.Minute)} + case "resolved": + nudges = []ipc.Nudge{{ID: 4, Ts: snoozeNow, Rule: "water", Outcome: store.NudgeActed}} + } + t.Run(name, func(t *testing.T) { + h, api := snoozeHandler(nudges) + reply, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice) + if handled || reply != "" { + t.Fatalf("got (%q, %v), want fall-through", reply, handled) + } + if api.calls != 0 { + t.Fatalf("resolved a nudge with nothing pending") + } + }) + } +} + +func TestResolveSnoozeIgnoresAFutureNudge(t *testing.T) { + // Clock skew between the tick and the turn must not let a send from the + // future be answered before it happened. + h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(5, -time.Minute)}) + if _, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice); handled { + t.Fatalf("snoozed a nudge dated in the future") + } + if api.calls != 0 { + t.Fatalf("resolved a future nudge") + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 5588332..09771cb 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -250,6 +250,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } + // 4b. spoken snooze — "не сейчас" / "потом" answers the nudge she just + // sent. Only handled when a pending nudge is actually inside the window + // (snooze.go); otherwise the words route normally, because "потом" is an + // ordinary word and eating every one of them would break real sentences. + if reply, handled := h.resolveSnooze(ctx, text, src); handled { + return withNotice(expiredNotice, reply) + } + // 5. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.now()) if err != nil {