Bug: spoken task capture is dead — the router calls the marker an act, and capture only rides the note intent #123
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
+48
-1
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши в список дел",
|
||||
"запиши в список задач",
|
||||
"запиши в список",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"поставь задачу",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user