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
+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 {