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 {