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. // taskCapturePrefixes — the leading phrases that mean "put this on the list". // A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить // молоко" is a capture, "я не добавил молоко в список" is him talking, and only // position tells them apart. // // Everything here is an explicit instruction. There is deliberately no entry // for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he // files, and a capture path that guesses would fill the list with his moods. var taskCapturePrefixes = []string{ "добавь в задачи", "добавь в список задач", "добавь в список дел", "добавь в список", "добавь задачу", "запиши в задачи", "запиши задачу", "новая задача", "в задачи", "add a task", "add task", "add to my tasks", "add to tasks", "new task", } // 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 } // urgencyMarkers — the words that set a weight, strongest first. Only these // two rungs: "срочно" is a deadline he has not named, "важно" is a preference, // and a third shade of urgent would be a distinction he never makes out loud. var urgencyMarkers = []struct { word string weight int }{ {"срочно", 3}, {"urgent", 3}, {"важно", 2}, {"important", 2}, } // 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 } // taskListWords — the nouns that make a question be about the task list. var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"} // taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?" // has no task noun in it at all, so it is matched as a phrase below. var taskListWordsShortcut = []string{"задачи", "задач", "tasks"} // 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 }