c259ed6c73
Step 4 of the board build (docs/plans/15-board-surface.md). Naming a task instead of its position reached nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn, and the gate asked "Что сделать?". The position path already worked through resolveCandidate, but only in the two turns after she read the list out. TaskStatusGrammar is the same shape TaskCaptureGrammar uses — matches broadly, decides in Build, no eighth intent — and fills the fn slot with task_status, which is neither a Hexis capability nor a Praxis one. Three conditions, all required: the board noun, so no ordinary sentence claims a turn; exactly one status class, since "готово, убери" names two and asking beats picking; and a status word matched as an imperative exactly or a stative by lemma. So a bare "готово" and a bare "закрой" are not this rule's, and the second belongs to Praxis, which claims it already. Two lexicon sets rather than one with a value. The store records which of the two transitions happened and /tasks shows it: work he chose to stop is not work he did. Measured on the fixture, two new cases (ru-act-020, ru-act-021). Classifier + ONNX 62/89 (69.7%) → 64/91 (70.3%); cascade+llm 67/89 (75.3%) → 69/91 (75.8%, 80.2% intent-only) at p50 1.225s. Both new cases claimed at stage 0, no case regressed, clarify counts unchanged at 3 false / 1 missed. The task's own warning stands: every such grammar runs its parser ahead of the resident model on every turn, so this is the last one that is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
179 lines
6.6 KiB
Go
179 lines
6.6 KiB
Go
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
|
||
},
|
||
}
|
||
}
|