diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 75ccc36..520d300 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -400,6 +400,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // 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. + // After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the + // board noun), and before the capture marker, which would otherwise read + // "убери из задач купить молоко" as a new task (Vikunja #512). + grammars = append(grammars, router.TaskStatusGrammar()) grammars = append(grammars, router.TaskCaptureGrammar()) // After the capture marker, so "запиши" still wins over "расскажи", and // last overall because it matches on the first word alone: "расскажи про diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index ab06327..1889d7c 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -64,7 +64,7 @@ func mustLoad() lexiconFile { "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", - "filler_particles", + "filler_particles", "task_done_words", "task_drop_words", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -113,6 +113,17 @@ func PartsOfDay() []string { return words("parts_of_day") } // ReminderVerbs returns the imperatives that open a reminder. func ReminderVerbs() []string { return words("reminder_verbs") } +// TaskDoneWords returns the words that finish a task, and TaskDropWords the +// words that abandon one. Two sets rather than one with a value, because the +// store records which of the two happened and the caller has to say so. +// +// Both mix moods on purpose, and the caller must match them the way the sets' +// notes say: an imperative exactly, a stative by lemma. +func TaskDoneWords() []string { return words("task_done_words") } + +// TaskDropWords — see TaskDoneWords. +func TaskDropWords() []string { return words("task_drop_words") } + // IsFillerParticle reports whether a word can never be the subject of a // request: a particle, a politeness word, or the first-person object. See the // set's own note for why this is not a stopword list. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index b3ee915..8feaf89 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -181,6 +181,22 @@ "давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной", "please", "just", "hey", "me" ] + }, + "task_done_words": { + "note": "The ways he says a task is finished, split by mood the way the Praxis lifecycle words are (Vikunja #512). The imperatives are addressed to her and are matched exactly, because morph.SameWord makes \"закрой\" and \"закрыл\" one word and only one of them is an instruction. The statives report his own day and are matched by lemma, since \"сделано\", \"сделана\" and \"сделанную\" are one state. Closed because these are her vocabulary for one transition, not a discovery about Russian.", + "words": [ + "закрой", "закройте", "закрыть", "заверши", "завершить", "close", "finish", + "сделано", "сделал", "сделала", "выполнено", "выполнил", "выполнила", + "готово", "готова", "закрыл", "закрыла", "done", "finished" + ] + }, + "task_drop_words": { + "note": "The ways he abandons a task rather than finishing it (Vikunja #512). Same two moods as task_done_words and the same matching rule. Separate from the done words because the store records which of the two happened and /tasks shows it: dropped work he chose to stop is not work he did.", + "words": [ + "убери", "уберите", "убрать", "удали", "удалить", "отмени", "отменить", + "drop", "remove", "cancel", + "передумал", "передумала", "неактуально" + ] } } } diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index b2f0ffb..b3ea537 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -243,6 +243,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter grammars = append(grammars, router.ListGrammars()...) grammars = append(grammars, router.ReminderGrammar()) grammars = append(grammars, router.PraxisGrammars()...) + grammars = append(grammars, router.TaskStatusGrammar()) grammars = append(grammars, router.TaskCaptureGrammar()) // "расскажи про X" is a world question the model called a fact, and the // rule goes last because it matches on the first word alone (Vikunja #498). diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 5fbef0b..18de608 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -70,6 +70,8 @@ { "id": "ru-act-004", "utterance": "включи вытяжку", "lang": "ru", "intent": "act", "want_fn": true }, { "id": "ru-act-005", "utterance": "запусти бэкап сейчас", "lang": "ru", "intent": "act", "want_fn": true }, { "id": "ru-act-006", "utterance": "закрой жалюзи", "lang": "ru", "intent": "act", "want_fn": true }, + { "id": "ru-act-020", "utterance": "закрой задачу купить молоко", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "a spoken status change over the board. Routed act with no allowlisted fn until V-512, so the gate asked \"Что сделать?\"; TaskStatusGrammar fills the fn slot with task_status and actionAct answers it from Maven's own store" }, + { "id": "ru-act-021", "utterance": "убери из задач оплатить интернет", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "the drop half of the same rule. Dropped work he chose to stop is not work he did, so the two status sets are separate lexicons" }, { "id": "en-act-001", "utterance": "maven, restart the media server", "lang": "en", "intent": "act", "want_fn": true, "tags": ["wake-token"] }, { "id": "en-act-002", "utterance": "turn off the kitchen light", "lang": "en", "intent": "act", "want_fn": true }, diff --git a/internal/router/taskstatus.go b/internal/router/taskstatus.go new file mode 100644 index 0000000..08a19be --- /dev/null +++ b/internal/router/taskstatus.go @@ -0,0 +1,178 @@ +package router + +import ( + "regexp" + "strings" + + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" + "github.com/kami/maven/internal/store" +) + +// A spoken status change over the task list (Vikunja #512, step 4 of +// docs/plans/15-board-surface.md). +// +// Capture and the list read were built; moving a task was only possible in the +// two turns after she read one out, through resolveCandidate — "первую сделал" +// against the bound list. Naming the task instead of its position reached +// nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn, +// and the gate asked "Что сделать?". +// +// Same shape TaskCaptureGrammar uses, for the same reason: no eighth intent, so +// the grammar matches broadly and a deterministic parser inside Build decides. +// The task's own warning applies — every such grammar runs its parser ahead of +// the resident model on every turn, so this is the last one that is free. +// +// TaskStatusFn is the fn slot the daemon dispatches on. Not a Hexis capability +// and not a Praxis one: the board is Maven's own store, so actionAct intercepts +// this name before either ecosystem client sees it. +const TaskStatusFn = "task_status" + +// TaskStatus — a parsed status change. Status is a store task status, and Text +// is the task he named, empty when he named none ("закрой задачу"), which is a +// turn the daemon claims and answers by asking which. +type TaskStatus struct { + Status string + Text string +} + +// taskStatusNouns — the noun that makes this a board turn rather than ordinary +// speech. Required, and it is the whole reason this rule is safe to run on every +// utterance: "готово" alone is him reporting his day, "убери" alone is a request +// about the room, and neither names the list. +// +// "дело" is deliberately absent. "в чём дело" and "дело в том" are ordinary +// speech, and "список дел" is already a list query. +var taskStatusNouns = []string{"task", "tasks", "todo", "todos"} + +// taskStatusFillers — the words to ignore when what is left over is the task he +// named. Prepositions and the possessive, because "убери из моих задач купить +// молоко" names the same task as "убери задачу купить молоко". +var taskStatusFillers = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"} + +// ParseTaskStatus reads a status change over the board: which transition, and +// which task. +// +// Three conditions, all required. A task noun, so no ordinary sentence claims +// the turn. Exactly one status class, because "готово, убери" names two and +// asking beats picking. And a status word that is either an imperative in the +// exact form he said it or a stative by lemma — the trap quiet_toggle.go +// documents, where "закрой" and "закрыл" are one lemma and only one is a +// command. +func ParseTaskStatus(text string) (TaskStatus, bool) { + toks := praxisTokens(strings.ToLower(strings.TrimSpace(text))) + if len(toks) == 0 || !taskStatusNamesBoard(toks) { + return TaskStatus{}, false + } + status := "" + for _, c := range []struct { + status string + words []string + }{ + {store.TaskDone, lexicon.TaskDoneWords()}, + {store.TaskDropped, lexicon.TaskDropWords()}, + } { + if !taskStatusHasWord(toks, c.words) { + continue + } + if status != "" { + // Two transitions in one sentence. They are different rows on the + // page, so this declines and the cascade answers. + return TaskStatus{}, false + } + status = c.status + } + if status == "" { + return TaskStatus{}, false + } + return TaskStatus{Status: status, Text: taskStatusReferent(toks)}, true +} + +// taskStatusNamesBoard reports whether the sentence names the task list. The +// Russian noun is matched by lemma, because a noun means the same thing in every +// case and he says "из задач", "задачу", "задача" for one list. +func taskStatusNamesBoard(toks []string) bool { + for _, t := range toks { + if morph.SameWord(t, "задача") { + return true + } + for _, n := range taskStatusNouns { + if t == n { + return true + } + } + } + return false +} + +// taskStatusHasWord matches a status word the way its set's note requires: an +// imperative exactly, a stative by lemma. It cannot tell the two columns apart +// from the data, so it tries the exact form first and then the lemma — which +// costs the imperative trap back, except that both columns of one set mean the +// SAME transition. "закрой" and "закрыл" are one lemma and, here, one status. +func taskStatusHasWord(toks, words []string) bool { + for _, t := range toks { + for _, w := range words { + if t == w || morph.SameWord(t, w) { + return true + } + } + } + return false +} + +// taskStatusReferent is what is left after the status words, the board noun and +// the fillers: the task he named, or "" when he named none. +// +// Word order is kept, because the leftover is matched against stored task text +// and he says the task the way he first said it. +func taskStatusReferent(toks []string) string { + done, drop := lexicon.TaskDoneWords(), lexicon.TaskDropWords() + var out []string + for _, t := range toks { + switch { + case taskStatusHasWord([]string{t}, done), taskStatusHasWord([]string{t}, drop): + case morph.SameWord(t, "задача"), taskStatusIn(t, taskStatusNouns): + case taskStatusIn(t, taskStatusFillers), lexicon.IsFillerParticle(t): + default: + out = append(out, t) + } + } + return strings.Join(out, " ") +} + +func taskStatusIn(tok string, words []string) bool { + for _, w := range words { + if tok == w { + return true + } + } + return false +} + +// TaskStatusGrammar — stage 0 for a spoken status change. Wired after the Praxis +// rules and before the capture marker: Praxis claims a bare "закрой" and this +// rule requires the board noun, so the two cannot collide, and the capture +// marker must not read "убери из задач купить молоко" as a new task. +func TaskStatusGrammar() Grammar { + return Grammar{ + Name: "task-status", + Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`), + Build: func(m []string) (Decision, bool) { + c, ok := ParseTaskStatus(m[1]) + if !ok { + return Decision{}, false + } + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + // Value carries the transition and Text the task he named, + // which is the pairing handlePraxisAct uses for an item and its + // reference. Empty Text is a claim, not a refusal: the daemon + // asks which task, having the list she does not. + Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text}, + }, true + }, + } +} diff --git a/internal/router/taskstatus_test.go b/internal/router/taskstatus_test.go new file mode 100644 index 0000000..0131667 --- /dev/null +++ b/internal/router/taskstatus_test.go @@ -0,0 +1,68 @@ +package router + +import "testing" + +func TestParseTaskStatus(t *testing.T) { + cases := []struct { + utterance string + ok bool + status string + text string + }{ + // The shapes that reached nothing before this rule. + {"закрой задачу купить молоко", true, "done", "купить молоко"}, + {"задачу купить молоко сделал", true, "done", "купить молоко"}, + {"убери из задач купить молоко", true, "dropped", "купить молоко"}, + {"убери из моих задач купить молоко", true, "dropped", "купить молоко"}, + {"отмени задачу оплатить интернет", true, "dropped", "оплатить интернет"}, + {"task buy milk done", true, "done", "buy milk"}, + // The referent may be missing. The turn is still his, and the daemon has + // the list to ask about. + {"закрой задачу", true, "done", ""}, + {"убери задачу", true, "dropped", ""}, + // No board noun: ordinary speech, and every one of these means something + // else. "закрой" alone belongs to Praxis. + {"готово", false, "", ""}, + {"закрой", false, "", ""}, + {"убери со стола", false, "", ""}, + {"я всё сделал", false, "", ""}, + {"закрой шторы в комнате", false, "", ""}, + // The board noun with no status word is a list query, not a move. + {"какие у меня задачи", false, "", ""}, + {"добавь в задачи купить молоко", false, "", ""}, + // Two transitions in one sentence. Asking beats picking. + {"задачу купить молоко готово убери", false, "", ""}, + {"", false, "", ""}, + } + for _, c := range cases { + got, ok := ParseTaskStatus(c.utterance) + if ok != c.ok { + t.Errorf("ParseTaskStatus(%q) ok = %v, want %v", c.utterance, ok, c.ok) + continue + } + if !ok { + continue + } + if got.Status != c.status || got.Text != c.text { + t.Errorf("ParseTaskStatus(%q) = %+v, want status %q text %q", c.utterance, got, c.status, c.text) + } + } +} + +func TestTaskStatusGrammarFillsTheFnSlot(t *testing.T) { + g := TaskStatusGrammar() + m := g.Pattern.FindStringSubmatch("закрой задачу купить молоко") + if m == nil { + t.Fatal("pattern did not match") + } + dec, ok := g.Build(m) + if !ok { + t.Fatal("Build declined") + } + if dec.Intent != IntentAct || !dec.Slots.HasFn || dec.Slots.Fn != TaskStatusFn { + t.Fatalf("decision = %+v, want act with fn %q", dec, TaskStatusFn) + } + if dec.Slots.Value != "done" || dec.Slots.Text != "купить молоко" { + t.Fatalf("slots = %+v, want value done text \"купить молоко\"", dec.Slots) + } +}