package router import "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) rest = strings.TrimRight(rest, ".!") rest, weight := stripUrgency(rest) if rest == "" { return TaskCapture{}, false } return TaskCapture{Text: rest, Weight: weight}, true } // 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. // // The word is removed from the text, because the list should read "оплатить // интернет (важно)" and not "важно оплатить интернет (важно)". func stripUrgency(text string) (string, int) { for _, m := range urgencyMarkers { lower := strings.ToLower(text) switch { case strings.HasPrefix(lower, m.Word+" "): return strings.TrimSpace(text[len(m.Word):]), m.Weight case strings.HasSuffix(lower, " "+m.Word): return strings.TrimSpace(text[:len(text)-len(m.Word)]), m.Weight case lower == m.Word: // Nothing but the marker — no task in it. return "", 0 } } return text, 0 } // 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. if (hasTok(toks, "что") || hasTok(toks, "чем")) && (hasTok(toks, "сделать") || hasTok(toks, "заняться")) { return true } if hasTok(toks, "what") && hasTok(toks, "do") { 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 }