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 }, } }