package router import ( "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"} // taskStatusTrailingFrame — grammar words that may remain after the named task. // Words before the board noun are excluded by bounds; this set is used only at // the trailing edge, so a title such as "сходить в банк" keeps its preposition. var taskStatusTrailingFrame = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"} // taskStatusTopicFrame reuses the closed possessive/topic grammar already used // to identify a committed reminder's subject. It describes the noun phrase, // not the reminder itself, so "задача про бэкапы" has the same boundary. var taskStatusTopicFrame = lexicon.ReminderCancelFrame() // ParseTaskStatus reads a status change over the board: which transition, and // which task. // // Four conditions, all required. A task noun, so no ordinary sentence claims // the turn. Exactly one status class, because two transitions mean asking beats // picking. Exact command vocabulary at command position, OR a result state by // lemma inside an independently authorised "mark task as state" frame. The // split is load-bearing: "закрой" and "закрыл" share a lemma, while only one is // addressed to Maven. Finally, questions and direct prohibitions decline before // the stage-0 decision can expose a write slot. func ParseTaskStatus(text string) (TaskStatus, bool) { toks := praxisTokens(strings.ToLower(strings.TrimSpace(text))) if len(toks) == 0 || !taskStatusNamesBoard(toks) || IsCommandProhibition(text) { return TaskStatus{}, false } status := "" statusAt := len(toks) for _, c := range []struct { status string commands []string states []string }{ {store.TaskDone, lexicon.TaskDoneCommands(), lexicon.TaskDoneStates()}, {store.TaskDropped, lexicon.TaskDropCommands(), lexicon.TaskDropStates()}, } { at, ok := taskStatusTransitionIndex(toks, c.commands, c.states) if !ok { 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 statusAt = at } if status == "" { return TaskStatus{}, false } // An explicit result from the other transition still makes the sentence // contradictory even when it is not, by itself, mutation authority: // "задача готова, убери" says both completed and dropped. Decline instead // of silently privileging the one imperative. Result vocabulary is used // here only as conflict evidence, never to authorize a write. if (status == store.TaskDone && taskStatusHasState(toks, lexicon.TaskDropStates())) || (status == store.TaskDropped && taskStatusHasState(toks, lexicon.TaskDoneStates())) { return TaskStatus{}, false } if taskStatusQuestionShaped(text, toks, statusAt, status) { return TaskStatus{}, false } return TaskStatus{Status: status, Text: taskStatusReferent(toks, statusAt)}, true } // taskStatusQuestionShaped keeps questions out of the mutating stage-0 rule. // The general IsQuestionShaped predicate lets an explicit capture verb win — // "запиши что я пил воду" is a write, not a query. That precedence cannot be // reused here: TaskStatusGrammar runs before capture, and // "запиши как отменить задачу" must remain a capture/query rather than become // a task deletion merely because its later infinitive is in TaskDropWords. // // A question mark is conclusive. Without punctuation, a closed interrogative // or narrative token before the status word makes the status word the subject // of a question ("как отменить задачу", "объясни как закрыть задачу"). A token // after an already stated change may belong to the stored task's name — // "отмени задачу узнать когда рейс" — so it is not enough to reverse a clear // command. Hyphenated indefinite pronouns remain one token under praxisTokens, // hence "отмени задачу купить что-нибудь" is not mistaken for a question. func taskStatusQuestionShaped(text string, toks []string, statusAt int, status string) bool { if strings.Contains(text, "?") && !taskStatusPoliteModalAt(toks, statusAt) { return true } for i, tok := range toks { if i >= statusAt || !taskStatusQuestionToken(tok) { continue } // "отметь задачу как сделанную" uses как as a state marker, not // an interrogative. The marker verb and board noun must both precede // it, and a stative resolve word must follow it; this deliberately // does not excuse "отметь как отменить задачу". if tok == "как" && status == store.TaskDone && taskStatusDoneMarker(toks, i, statusAt) { continue } return true } return false } func taskStatusQuestionToken(tok string) bool { return taskStatusIn(tok, interrogatives) || taskStatusIn(tok, narrativeRequests) } func taskStatusDoneMarker(toks []string, at, statusAt int) bool { markerAt, joinAt, ok := taskStatusMarkerFrame(toks, statusAt) return ok && joinAt == at && markerAt < joinAt } // 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 taskStatusIsBoardNoun(t) { return true } } return false } // taskStatusTransitionIndex separates authority from state. A command form is // exact and must occupy the command head; a result word may use morphology only // after an explicit marker command has already supplied authority. func taskStatusTransitionIndex(toks, commands, states []string) (int, bool) { for i, tok := range toks { if taskStatusIn(tok, commands) && taskStatusCommandHead(toks, i) { return i, true } } for i, tok := range toks { for _, state := range states { if (tok == state || morph.SameWord(tok, state)) && taskStatusStateFrame(toks, i) { return i, true } } } return 0, false } func taskStatusHasState(toks, states []string) bool { for _, tok := range toks { for _, state := range states { if tok == state || morph.SameWord(tok, state) { return true } } } return false } // taskStatusCommandHead proves that the transition is addressed rather than a // plan/report containing an infinitive. Filler and Maven's address may lead a // command. Russian also permits the board noun first ("задачу X закрой") and // the bounded negative-polarity politeness frame "не мог бы ты закрыть". func taskStatusCommandHead(toks []string, at int) bool { if at < 0 || at >= len(toks) { return false } start := 0 for start < at && commandLead(toks[start]) { start++ } if start == at { return true } if taskStatusPoliteModalPrefix(toks[start:at]) { return true } return start < at && taskStatusIsBoardNoun(toks[start]) } func taskStatusPoliteModalPrefix(prefix []string) bool { return len(prefix) == 4 && prefix[0] == "не" && morph.SameWord(prefix[1], "мочь") && prefix[2] == "бы" && (prefix[3] == "ты" || prefix[3] == "вы") } func taskStatusPoliteModalAt(toks []string, at int) bool { start := 0 for start < at && commandLead(toks[start]) { start++ } return at >= start && taskStatusPoliteModalPrefix(toks[start:at]) } // taskStatusStateFrame proves the result word is subordinate to an exact // marker command. A bare "задача готова" or "я сделал задачу" is a report and // carries no mutation authority, even though it names both board and state. func taskStatusStateFrame(toks []string, statusAt int) bool { _, _, ok := taskStatusMarkerFrame(toks, statusAt) return ok } func taskStatusMarkerFrame(toks []string, statusAt int) (markerAt, joinAt int, ok bool) { for join := statusAt - 1; join >= 0; join-- { if toks[join] != "как" && toks[join] != "as" { continue } for marker := 0; marker < join; marker++ { if !taskStatusIn(toks[marker], praxisMarkerVerbs) || !taskStatusCommandHead(toks, marker) { continue } if taskStatusNamesBoard(toks[marker+1:join]) || taskStatusNamesBoard(toks[:marker]) { return marker, join, true } } } return 0, 0, false } // taskStatusReferent reads the task out of the command frame, rather than // subtracting every word that can occur in that frame. Subtraction mangles a // real title such as "сходить в банк", and in the marker shape // // отметь задачу про бэкапы как сделанную // // it left the framing verb in the identity. The board noun and resolved status // word give this grammar real boundaries. The dedicated marker frame handles // both word orders around "отметь"; everything else keeps the words between // the board noun and status (or after the noun when the imperative leads). func taskStatusReferent(toks []string, statusAt int) string { if ref, ok := taskStatusMarkerReferent(toks, statusAt); ok { return strings.Join(taskStatusTrimReferent(ref), " ") } boardAt, ok := taskStatusBoardIndex(toks, statusAt) if !ok { return "" } lo, hi := boardAt+1, len(toks) if statusAt > boardAt { hi = statusAt } if lo > hi { return "" } return strings.Join(taskStatusTrimReferent(toks[lo:hi]), " ") } // taskStatusMarkerReferent recognises the already-validated // "mark task X as done" frame and returns X. A leading marker wins over any // marker-shaped verb inside X ("отметь задачу отметить выходные ..."). // With postposed Russian word order, the last marker before "как" closes X. func taskStatusMarkerReferent(toks []string, statusAt int) ([]string, bool) { for joinAt := statusAt - 1; joinAt >= 0; joinAt-- { if toks[joinAt] != "как" && toks[joinAt] != "as" { continue } if !taskStatusDoneMarker(toks, joinAt, statusAt) { continue } // Canonical order: marker, board noun, referent, state joiner. for markerAt := 0; markerAt < joinAt; markerAt++ { if !taskStatusIn(toks[markerAt], praxisMarkerVerbs) { continue } for boardAt := markerAt + 1; boardAt < joinAt; boardAt++ { if taskStatusIsBoardNoun(toks[boardAt]) { return toks[boardAt+1 : joinAt], true } } } // Postposed order: board noun, referent, marker, state joiner. for markerAt := joinAt - 1; markerAt >= 0; markerAt-- { if !taskStatusIn(toks[markerAt], praxisMarkerVerbs) { continue } for boardAt := markerAt - 1; boardAt >= 0; boardAt-- { if taskStatusIsBoardNoun(toks[boardAt]) { return toks[boardAt+1 : markerAt], true } } } } return nil, false } func taskStatusBoardIndex(toks []string, statusAt int) (int, bool) { // A leading imperative owns the first board noun after it. Looking there // first avoids treating a later "задача" inside the title as the frame. for i := statusAt + 1; i < len(toks); i++ { if taskStatusIsBoardNoun(toks[i]) { return i, true } } for i := 0; i < statusAt && i < len(toks); i++ { if taskStatusIsBoardNoun(toks[i]) { return i, true } } return 0, false } func taskStatusIsBoardNoun(tok string) bool { if morph.SameWord(tok, "задача") { return true } return taskStatusIn(tok, taskStatusNouns) } // taskStatusTrimReferent removes only words at the identity's edges. The topic // and possessive words are the same closed noun/subject frame reminder // cancellation already uses. Keeping interior words is load-bearing: Russian // task titles routinely contain prepositions. func taskStatusTrimReferent(toks []string) []string { for len(toks) > 0 && taskStatusIn(toks[0], taskStatusTopicFrame) { toks = toks[1:] } for len(toks) > 0 && (taskStatusIn(toks[len(toks)-1], taskStatusTrailingFrame) || taskStatusIn(toks[len(toks)-1], taskStatusTopicFrame) || lexicon.IsFillerParticle(toks[len(toks)-1])) { toks = toks[:len(toks)-1] } return toks } 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", Decide: func(utterance string) (Decision, bool) { c, ok := ParseTaskStatus(utterance) 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 }, } }