mavend: let a fact close the nudge that asked for it
The snooze wire had one end. "готово" and "выпил воды" both left the nudge pending, so the auto-tuner only ever learned from deferrals and from silence — never from the rule working. Two entry points, because the two utterances are different acts. A bare "готово" carries no content and is intercepted before the router, sharing pendingNudge and the twenty-minute window with the snooze. "выпил воды" IS content: it routes normally, writes its fact, and only then closes the nudge (ackFromFact, after applyAction). Folding the second into a pre-route intercept would have thrown away the thing he actually said. ackFromFact is silent. The fact reply stands; "отлично, отметила" on top would be her congratulating him for obeying, which is the nag she is not. Which fact answers which rule comes from the rule's own InertWhenNoData, so a rule added later is covered the day it lands. "да" and "ок" stay out of the ack vocabulary: the clarify gate upstream has the stronger claim on them, and a stray "да" must not rewrite the feedback signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user