87d176153a
Spoken capture was dead. "добавь в задачи купить молоко" routed act, so the gate found no allowlisted fn and asked "Что сделать?", and the list stayed empty. Capture rides the note intent by design (#130, no eighth intent), and nothing under actionNote was reached any more. The model also rewrote the payload on the way — "купить молоко" came back as "сделать покупку молока", and a task must read as the words he said. TaskCaptureGrammar answers it at stage 0, the same place the agenda rules went. It matches any utterance and lets ParseTaskCapture refuse, so the marker list stays data. Three phrasings he used are added to that list: "запиши в список дел" and the two next to it were missing. The other deterministic matchers were checked for the same exposure. They are all question-shaped — money, habit, feed, day plan, task list, calendar — and a question lands on query, which is where they already sit. Capture was the only imperative among them, which is why only it was taken. ru-note-006 is the fixture case. The classifier alone cannot pass it, and the hash baseline drops by that one case; the daemon answers it at stage 0.
125 lines
3.7 KiB
Go
125 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/memory"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/store"
|
|
"github.com/kami/maven/internal/tool"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
func TestReactiveNotesReminders(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Now()
|
|
|
|
emb := router.NewHashEmbedder(1024)
|
|
matcher := tool.NewMatcher(api)
|
|
rtr := buildRouter(emb, matcher, 0.55, nil)
|
|
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
embedder: emb,
|
|
router: rtr,
|
|
replier: voice.NewStubReplier(),
|
|
now: func() time.Time { return now },
|
|
memStore: memory.NewInMemoryStore(),
|
|
dataStore: st,
|
|
}
|
|
|
|
t.Run("reminder capture", func(t *testing.T) {
|
|
fireAt := now.Add(24 * time.Hour).Truncate(time.Millisecond)
|
|
dec := router.Decision{
|
|
Intent: router.IntentReminder,
|
|
Utterance: "напомни завтра позвонить маме",
|
|
Slots: router.Slots{
|
|
Text: "завтра позвонить маме",
|
|
Time: fireAt,
|
|
HasTime: true,
|
|
},
|
|
}
|
|
reply := h.applyAction(ctx, dec)
|
|
if reply != "" {
|
|
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
|
}
|
|
reminders, err := st.ListReminders(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListReminders: %v", err)
|
|
}
|
|
if len(reminders) == 0 {
|
|
t.Fatal("expected at least one reminder, got none")
|
|
}
|
|
last := reminders[0]
|
|
if last.FireTs.UnixMilli() != fireAt.UnixMilli() {
|
|
t.Errorf("reminder FireTs = %v, want %v", last.FireTs, fireAt)
|
|
}
|
|
if last.Status != "pending" {
|
|
t.Errorf("reminder Status = %q, want pending", last.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("note capture", func(t *testing.T) {
|
|
dec := router.Decision{
|
|
Intent: router.IntentNote,
|
|
Utterance: "запомни что кофе закончился",
|
|
}
|
|
reply := h.applyAction(ctx, dec)
|
|
if reply != "" {
|
|
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
|
}
|
|
notes, err := st.RecentNotes(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentNotes: %v", err)
|
|
}
|
|
if len(notes) == 0 {
|
|
t.Fatal("expected at least one note, got none")
|
|
}
|
|
last := notes[0]
|
|
if last.Text != "запомни что кофе закончился" {
|
|
t.Errorf("note text = %q, want %q", last.Text, "запомни что кофе закончился")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
|
// task table. It went dead when the router started claiming the marker as an
|
|
// act: capture rides the note intent, so nothing below actionNote was ever
|
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Now()
|
|
emb := router.NewHashEmbedder(1024)
|
|
matcher := tool.NewMatcher(api)
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
embedder: emb,
|
|
router: buildRouter(emb, matcher, 0.55, nil),
|
|
replier: voice.NewStubReplier(),
|
|
now: func() time.Time { return now },
|
|
memStore: memory.NewInMemoryStore(),
|
|
dataStore: st,
|
|
}
|
|
|
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
|
}
|
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
|
if err != nil || len(open) != 1 {
|
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
|
}
|
|
// The words he said, not the model's rewrite of them.
|
|
if open[0].Text != "купить молоко" {
|
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
|
}
|
|
}
|