mavend: let him say "потом" to a nudge

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 21:40:06 +04:00
parent ba1d8e3f44
commit 79c3b994cf
4 changed files with 243 additions and 7 deletions
+13 -7
View File
@@ -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.
---
+107
View File
@@ -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
}
+115
View File
@@ -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")
}
}
+8
View File
@@ -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 {