d0d0021659
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
110 lines
3.5 KiB
Go
110 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|