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
|
вернусь к этому позже.` and the nudge row on `/notifications` reading
|
||||||
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
||||||
ordinary utterance, not be swallowed.
|
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.
|
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
|
**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)
|
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.
|
// 5. router — classify the utterance.
|
||||||
dec, err := h.router.Route(ctx, text, h.now())
|
dec, err := h.router.Route(ctx, text, h.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,6 +306,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
replyText := h.applyAction(ctx, dec)
|
replyText := h.applyAction(ctx, dec)
|
||||||
log.Printf("voice: applyAction returned: %q", replyText)
|
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.
|
// 9. replier — phrase the reply across the router decision.
|
||||||
if replyText == "" {
|
if replyText == "" {
|
||||||
replyText = h.replier.Reply(dec)
|
replyText = h.replier.Reply(dec)
|
||||||
|
|||||||
Reference in New Issue
Block a user