diff --git a/QA-PLAN.md b/QA-PLAN.md index 913d853..e7dea73 100644 --- a/QA-PLAN.md +++ b/QA-PLAN.md @@ -53,7 +53,11 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures). вернусь к этому позже.` 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 +8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact + reply and nothing extra — she must not congratulate you. Check + `/notifications`: the row reads `acted`. Then trigger another nudge and say + `готово`; expect `отлично, отметила.` and the same outcome. +9. 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. **319 is fixed** (01-08-2026). Single-word Russian utterances no longer come diff --git a/cmd/mavend/ack.go b/cmd/mavend/ack.go new file mode 100644 index 0000000..9fcd430 --- /dev/null +++ b/cmd/mavend/ack.go @@ -0,0 +1,128 @@ +// Spoken ack — the other half of the snooze wire. "готово" said out loud +// resolves a live nudge as `acted`, and a fact that answers the nudge on its +// own ("выпил воды" after the water rule fired) closes it without him having +// to say anything extra. +// +// Two entry points rather than one, because the two utterances are different +// acts. A bare "готово" carries no content and is intercepted before the +// router, exactly like the snooze. "выпил воды" IS content: it has to route +// normally and write its fact, and only then close the nudge. Folding the +// second into a pre-route intercept would have thrown the fact away, which is +// the thing he actually said. +package main + +import ( + "context" + "log" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +// resolveAck — pre-route keyword check for a contentless acknowledgement, +// run after the snooze. Same window and same fall-through rule: the words only +// count when a nudge is actually live, so "готово" with nothing pending routes +// normally. +func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnSource) (string, bool) { + if !classifyAck(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.NudgeActed, now); err != nil { + log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) + return "не получилось отметить.", true + } + log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src) + return "отлично, отметила.", true +} + +// ackFromFact — post-action hook, called once the turn's decision has been +// applied. A fact whose key is the substrate of a live nudge's rule answers +// that nudge, so the nudge is resolved `acted` and the auto-tuner learns the +// rule is working. +// +// Silent by design: it returns nothing and never changes the reply. He said +// "выпил воды" and the fact reply is what he is owed; "отлично, отметила" on +// top would be her congratulating him for obeying, which is the nag she is +// explicitly not. +// +// Best-effort throughout. A failure here loses one feedback signal and must +// never turn a written fact into an error the user hears. +func (h *reactiveHandler) ackFromFact(ctx context.Context, dec router.Decision) { + if dec.Clarify || dec.Intent != router.IntentFact || !dec.Slots.HasKey { + return + } + rules := ackRulesForKey(dec.Slots.Key) + if len(rules) == 0 { + return + } + now := h.now() + target, ok := h.pendingNudge(ctx, now) + if !ok || !rules[target.Rule] { + return + } + if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil { + log.Printf("voice: ack nudge %d from fact %q: %v", target.ID, dec.Slots.Key, err) + return + } + log.Printf("voice: nudge %d (rule %s) acked by fact %q", target.ID, target.Rule, dec.Slots.Key) +} + +// ackRulesForKey — which rules a fact under this key answers. +// +// Derived from each rule's InertWhenNoData rather than written out as a map, +// so a rule added later is covered the day it lands. That field already names +// the substrate the rule reads; a fresh fact under one of those keys is by +// definition the thing the rule was complaining about the absence of. +// +// DefaultRules, not the daemon's wired set: a rule disabled in config cannot +// have a pending nudge to close anyway, and reading the canonical set here +// keeps this free of the config plumbing. +func ackRulesForKey(key string) map[string]bool { + if key == "" { + return nil + } + var out map[string]bool + for _, r := range loop.DefaultRules() { + for _, k := range r.InertWhenNoData { + if k != key { + continue + } + if out == nil { + out = map[string]bool{} + } + out[r.Name] = true + } + } + return out +} + +// ackPhrases — the acknowledgement vocabulary, as stem sequences. Matched by +// quietPhrase (quiet_toggle.go), so a single-word pattern matches only a +// single-word utterance. +// +// "да" and "ок" are deliberately absent. Both are answers to a question she +// asked, and the clarify gate upstream (resolveClarifyAnswer) has the stronger +// claim on them; letting them close a nudge as well would mean a stray "да" +// silently rewrites the feedback the auto-tuner learns from. +var ackPhrases = [][]string{ + {"готово"}, {"сделал"}, {"сделано"}, {"выполнил"}, {"уже"}, + {"уже", "сделал"}, {"уже", "готово"}, {"всё", "сделал"}, + {"done"}, {"already", "did"}, +} + +// classifyAck reads an utterance as a contentless acknowledgement. +func classifyAck(text string) bool { + tokens := quietTokens(text) + for _, p := range ackPhrases { + if quietPhrase(tokens, p) { + return true + } + } + return false +} diff --git a/cmd/mavend/ack_test.go b/cmd/mavend/ack_test.go new file mode 100644 index 0000000..2526c9f --- /dev/null +++ b/cmd/mavend/ack_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +func TestClassifyAck(t *testing.T) { + for _, s := range []string{ + "готово", "сделал", "сделано", "выполнил", "уже", + "уже сделал", "всё сделал", "done", + } { + if !classifyAck(s) { + t.Errorf("classifyAck(%q) = false, want true", s) + } + } + for _, s := range []string{ + // "да" and "ок" belong to the clarify gate, not to the nudge. + "да", "ок", "хорошо", + // A single-word pattern must not eat the sentence it appears in. + "сделал бэкап базы", "готово ли обновление", "уже поздно", + "напомни завтра позвонить маме", "", + } { + if classifyAck(s) { + t.Errorf("classifyAck(%q) = true, want false", s) + } + } +} + +func TestResolveAckMarksTheNudgeActed(t *testing.T) { + h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(6, 2*time.Minute)}) + reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice) + if !handled || reply == "" { + t.Fatalf("got (%q, %v), want a reply", reply, handled) + } + if api.gotID != 6 || api.gotOutcome != store.NudgeActed { + t.Fatalf("resolved (%d, %q), want (6, %q)", api.gotID, api.gotOutcome, store.NudgeActed) + } +} + +func TestResolveAckFallsThroughWithNothingPending(t *testing.T) { + h, api := snoozeHandler(nil) + if reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice); 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 TestAckRulesForKey(t *testing.T) { + cases := []struct { + key string + want string // "" means no rule + }{ + {"water", "water"}, + {"meal", "meal"}, + {"break", "break"}, + {"desk_active", "break"}, + {"weight", ""}, + {"", ""}, + } + for _, tc := range cases { + got := ackRulesForKey(tc.key) + if tc.want == "" { + if len(got) != 0 { + t.Errorf("ackRulesForKey(%q) = %v, want none", tc.key, got) + } + continue + } + if !got[tc.want] { + t.Errorf("ackRulesForKey(%q) = %v, want %q in it", tc.key, got, tc.want) + } + } +} + +func TestAckFromFactClosesTheMatchingNudge(t *testing.T) { + h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(11, time.Minute)}) // rule "water" + h.ackFromFact(context.Background(), router.Decision{ + Intent: router.IntentFact, + Slots: router.Slots{Key: "water", HasKey: true}, + }) + if api.gotID != 11 || api.gotOutcome != store.NudgeActed { + t.Fatalf("resolved (%d, %q), want (11, %q)", api.gotID, api.gotOutcome, store.NudgeActed) + } +} + +func TestAckFromFactIgnoresAnUnrelatedFact(t *testing.T) { + // The live nudge is "water"; a meal fact does not answer it. Closing it + // anyway would tell the auto-tuner the water rule works when he ignored it. + h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(12, time.Minute)}) + for _, dec := range []router.Decision{ + {Intent: router.IntentFact, Slots: router.Slots{Key: "meal", HasKey: true}}, + {Intent: router.IntentFact, Slots: router.Slots{Key: "weight", HasKey: true}}, + {Intent: router.IntentFact}, // no key + {Intent: router.IntentQuery, Slots: router.Slots{Key: "water", HasKey: true}}, + {Intent: router.IntentFact, Slots: router.Slots{Key: "water", HasKey: true}, Clarify: true}, + } { + h.ackFromFact(context.Background(), dec) + } + if api.calls != 0 { + t.Fatalf("resolved %d nudge(s) on unrelated decisions", api.calls) + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 09771cb..b95bc35 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -258,6 +258,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } + // 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the + // contentless form is intercepted here; "выпил воды" keeps routing and + // closes the nudge after its fact lands (ackFromFact, step 8b). + if reply, handled := h.resolveAck(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 { @@ -299,6 +306,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour replyText := h.applyAction(ctx, dec) log.Printf("voice: applyAction returned: %q", replyText) + // 8b. a fact that answers a live nudge closes it as `acted` (ack.go). + // Silent: the fact reply stands, she does not congratulate him for it. + h.ackFromFact(ctx, dec) + // 9. replier — phrase the reply across the router decision. if replyText == "" { replyText = h.replier.Reply(dec)