router: stage 0 claims the task marker before the model renames it (V-467)
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.
This commit is contained in:
@@ -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