diff --git a/cmd/mavend/reactive_notes_test.go b/cmd/mavend/reactive_notes_test.go index 66f93dd..b53512c 100644 --- a/cmd/mavend/reactive_notes_test.go +++ b/cmd/mavend/reactive_notes_test.go @@ -2,12 +2,14 @@ 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" ) @@ -85,3 +87,38 @@ func TestReactiveNotesReminders(t *testing.T) { } }) } + +// 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) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index f4fd334..4df771b 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -380,6 +380,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // is an agenda question and must not. grammars = append(grammars, router.AgendaQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) + // Last, and it matches any utterance shape — its Build is the filter. An + // explicit capture marker beats the model, which called it an act and + // rewrote the task text (Vikunja #467). After the rules above because a + // marker never collides with a clock or agenda question. + grammars = append(grammars, router.TaskCaptureGrammar()) return router.New(router.Config{ Grammars: grammars, Classifier: cls, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 2f6004e..44b90e8 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -71,6 +71,7 @@ { "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] }, { "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" }, { "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" }, + { "id": "ru-note-006", "utterance": "добавь в задачи купить молоко", "lang": "ru", "intent": "note", "tags": ["capture"], "note": "an explicit capture marker — the model called it an act and rewrote the payload (Vikunja #467), stage 0 claims it" }, { "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] }, { "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] }, diff --git a/internal/router/task.go b/internal/router/task.go index c85059b..96b0e2e 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -1,6 +1,9 @@ package router -import "strings" +import ( + "regexp" + "strings" +) // Task capture and task listing, matched deterministically (Vikunja #130). // @@ -201,3 +204,47 @@ func IsTaskListQuery(text string) bool { } return false } + +// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident +// model never sees it (Vikunja #467). +// +// Capture was built to ride the note intent, deliberately: #130 said no eighth +// intent, and while the classifier was routing, a note-shaped utterance with a +// marker in it reached actionNote and captureTaskFromNote claimed it there. The +// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко" +// routed act, so captureTaskFromNote was never consulted, the act arm found no +// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance +// tried filed nothing. +// +// The model also rewrote the payload on the way — "купить молоко" came back as +// "сделать покупку молока". A task must read as the words he said, which is a +// second reason to answer this before the model rather than to prompt around +// it. +// +// The marker list is data (task_phrases.json) and the parse strips urgency, so +// the pattern here matches any utterance and the decision is ParseTaskCapture's +// to make — same shape as the wake-word act grammar, which also matches broadly +// and refuses in Build. Intent stays note: the daemon's note path is where +// capture lives, and nothing about the contract with the model changes. +func TaskCaptureGrammar() Grammar { + return Grammar{ + Name: "task-capture", + Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`), + Build: func(m []string) (Decision, bool) { + c, ok := ParseTaskCapture(m[1]) + if !ok { + return Decision{}, false // not a capture — fall through + } + return Decision{ + Stage: 0, + Intent: IntentNote, + Confidence: 1.0, + // The capture text, not the raw utterance: it is what the + // clarify gate reads as the payload. captureTaskFromNote + // re-parses the utterance itself, so the task text comes from + // the same place either way. + Slots: Slots{Text: c.Text}, + }, true + }, + } +} diff --git a/internal/router/task_phrases.json b/internal/router/task_phrases.json index f70eff3..6ed9250 100644 --- a/internal/router/task_phrases.json +++ b/internal/router/task_phrases.json @@ -27,6 +27,9 @@ "добавь в список", "добавь задачу", "запиши в задачи", + "запиши в список дел", + "запиши в список задач", + "запиши в список", "запиши задачу", "новая задача", "поставь задачу", diff --git a/internal/router/task_test.go b/internal/router/task_test.go index 75503f5..83e5ffd 100644 --- a/internal/router/task_test.go +++ b/internal/router/task_test.go @@ -85,3 +85,37 @@ func TestIsTaskListQuery(t *testing.T) { } } } + +// TestTaskCaptureGrammarClaimsTheMarker — the capture marker is answered at +// stage 0, so the model never gets to call it an act (Vikunja #467). +func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) { + g := TaskCaptureGrammar() + captures := map[string]string{ + "добавь в задачи купить молоко": "купить молоко", + "запиши в список дел купить хлеб": "купить хлеб", + "поставь задачу вынести мусор": "вынести мусор", + "добавь в задачи срочно оплатить дом": "оплатить дом", + } + for in, want := range captures { + m := g.Pattern.FindStringSubmatch(in) + if m == nil { + t.Fatalf("%q did not match the grammar pattern", in) + } + d, ok := g.Build(m) + if !ok { + t.Fatalf("%q must be claimed as a capture", in) + } + if d.Intent != IntentNote || d.Slots.Text != want { + t.Errorf("%q → intent=%s text=%q, want note/%q", in, d.Intent, d.Slots.Text, want) + } + } + // Everything without a marker falls through, including a marker with no + // task after it and a question about the list. + for _, in := range []string{"надо бы поспать", "добавь в задачи", "какие у меня задачи?", "перезапусти nginx"} { + if m := g.Pattern.FindStringSubmatch(in); m != nil { + if _, ok := g.Build(m); ok { + t.Errorf("%q must fall through to the cascade", in) + } + } + } +}