package router import ( "regexp" "strings" ) // Task capture and task listing, matched deterministically (Vikunja #130). // // No new intent. The router's intent enum is a contract shared with the // relabelling prompt in the training workspace (`llm/check_prompt_parity.py` // enforces it), so adding an eighth intent would mean retraining before a task // could be captured at all. A task phrased out loud is a note-shaped or // query-shaped utterance with an explicit marker in it, and the marker is a // lookup — the same reasoning the calendar, plan and habit matchers already // follow. What the model classifies is unchanged; what these functions decide // is which store the turn lands in. // The phrase tables this file matches against — taskCapturePrefixes, // urgencyMarkers, taskListWords, taskListWordsShortcut — are loaded from the // embedded task_phrases.json. See task_phrases.go. // TaskCapture — a parsed capture: the task itself, plus the importance he // stated out loud if he stated one (Vikunja #129). Weight 0 means he said // nothing about importance, which the ranker treats as exactly that — no // urgency is inferred from the wording. type TaskCapture struct { Text string Weight int } // ParseTaskCapture reports whether an utterance explicitly files a task, and // returns the task text with the marker stripped. A marker with nothing after it // is not a capture (there is no task in "добавь в задачи") — the caller falls // through to whatever it would otherwise have done with the turn. func ParseTaskCapture(text string) (TaskCapture, bool) { trimmed := strings.TrimSpace(text) lower := strings.ToLower(trimmed) best := "" for _, p := range taskCapturePrefixes { if strings.HasPrefix(lower, p) && len(p) > len(best) { best = p } } if best == "" { return TaskCapture{}, false } // Cut on the rune length of the matched prefix. ToLower does not change the // byte length of Russian or English letters, so the index carries over. rest := strings.TrimSpace(trimmed[len(best):]) rest = strings.TrimLeft(rest, ":—- ") rest = strings.TrimSpace(rest) // The question mark goes too. Whisper punctuates dictated Russian, and // "добавь в задачи позвонить в банк?" must not store the mark or carry it // into the dedupe key. rest = strings.TrimRight(rest, ".!?") rest, weight := stripUrgency(rest) if rest == "" { return TaskCapture{}, false } return TaskCapture{Text: rest, Weight: weight}, true } // urgencyIntensifiers — words that may sit between the edge and the marker. // "очень срочно оплатить интернет" is the marker at the edge with one word in // front of it, and it means exactly what "срочно оплатить интернет" means. var urgencyIntensifiers = []string{"очень", "прям", "прямо", "really", "very", "super"} // urgencyEdgeTrim — punctuation to ignore around an edge token and to clean off // the remainder afterwards. const urgencyEdgeTrim = " .,;:!?—-" // stripUrgency pulls a leading or trailing urgency word out of the task text // and returns the weight it implies. Only at the edges: "срочно оплатить // интернет" and "оплатить интернет срочно" are the same instruction, while // "позвонить в срочную помощь" is a task whose text happens to contain the // stem, and cutting a word out of the middle of it would mangle the task. // // Matched as a TOKEN, not as a fixed prefix or suffix string. The old shape // required exactly one space before a trailing marker, so "оплатить интернет, // срочно" — which is what whisper produces from dictated Russian — kept weight // 0 and stored the comma and the word as part of the task, polluting the dedupe // key with the very flag he was trying to set. // // The word is removed from the text, because the list should read "оплатить // интернет (важно)" and not "важно оплатить интернет (важно)". func stripUrgency(text string) (string, int) { fields := strings.Fields(text) if len(fields) == 0 { return text, 0 } for _, m := range urgencyMarkers { // Strongest marker first (task_phrases.go sorts them), leading edge // before trailing, so a text carrying both keeps the stronger one. if lo, hi, ok := urgencySpan(fields, m.Word); ok { rest := strings.Join(append(append([]string{}, fields[:lo]...), fields[hi+1:]...), " ") rest = strings.Trim(rest, urgencyEdgeTrim) if rest == "" { // Nothing but the marker — no task in it. return "", 0 } return rest, m.Weight } } return text, 0 } // urgencySpan finds the marker at either edge, allowing intensifiers between // the edge and the marker, and returns the inclusive token range to cut. func urgencySpan(fields []string, word string) (lo, hi int, ok bool) { for i := 0; i < len(fields); i++ { if isUrgencyToken(fields[i], word) { return 0, i, true } if !isIntensifier(fields[i]) { break } } for i := len(fields) - 1; i >= 0; i-- { if isUrgencyToken(fields[i], word) { return i, len(fields) - 1, true } if !isIntensifier(fields[i]) { break } } return 0, 0, false } func isUrgencyToken(tok, word string) bool { return strings.Trim(strings.ToLower(tok), urgencyEdgeTrim) == word } func isIntensifier(tok string) bool { t := strings.Trim(strings.ToLower(tok), urgencyEdgeTrim) for _, w := range urgencyIntensifiers { if t == w { return true } } return false } // IsTaskListQuery reports whether an utterance asks for the outstanding task // list — "какие у меня задачи?", "что мне нужно сделать?", "список дел". // // Narrow on purpose. "как дела?" is a greeting, not a query about work, and it // contains a task noun; it is excluded explicitly. Anything that mentions a // task noun without asking for the list falls through to ordinary recall. func IsTaskListQuery(text string) bool { toks := planTokens(text) if len(toks) == 0 { return false } // "как дела" — the greeting. Excluded before anything else matches. if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) { return false } // "что мне нужно сделать" / "чем мне заняться" — no task noun at all, so // the pronoun is what carries the meaning. Without it these rules claimed // every question with a verb in them: "что нужно сделать чтобы перезапустить // сервер?" and "what does docker do?" both answered "задач нет." from ahead // of recall and the model, which is the failure the source ordering exists // to avoid, pointed the other way. // // A "с"/"со" object excludes them too: "что мне сделать с этим файлом" has // the pronoun and is still a question about a file. if !hasTok(toks, "с") && !hasTok(toks, "со") { if hasTok(toks, "мне") && (hasTok(toks, "что") || hasTok(toks, "чем")) && (hasTok(toks, "сделать") || hasTok(toks, "делать") || hasTok(toks, "заняться")) { return true } if hasTok(toks, "what") && hasTok(toks, "do") && hasTok(toks, "i") && !hasTok(toks, "you") { return true } } hasNoun := false for _, t := range toks { for _, w := range taskListWords { if t == w { hasNoun = true } } } if !hasNoun { return false } // A task noun plus any of: a question word, "список", or a bare // one/two-word ask ("задачи", "мои задачи"). if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") || hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") || hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") || hasTok(toks, "show") { return true } if len(toks) <= 2 { for _, t := range toks { for _, w := range taskListWordsShortcut { if t == w { return true } } } } 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 }, } } // TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне // нужно сделать" (Vikunja #467). // // The same exposure the capture marker had, pointed the other way. IsTaskListQuery // is a deterministic lookup that lives inside a query source, so it is only // consulted once the turn is already IntentQuery. A phrasing the model calls // system or note never reaches it, and "пока не умею" is what he hears — the // failure the agenda and feed rules were written for. // // Placed after the agenda rules, which already send "какие у меня задачи" to // query. What this adds is the phrasings with no possessive in them. func TaskListGrammar() Grammar { return Grammar{ Name: "task-list-query", Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`), Build: func(m []string) (Decision, bool) { if !IsTaskListQuery(m[1]) { return Decision{}, false } return Decision{ Stage: 0, Intent: IntentQuery, Confidence: 1.0, Slots: Slots{Text: strings.TrimSpace(m[1])}, }, true }, } }