bf6ccf9aea
Ordering is computed, not generated. Asking a 1.7B which of his tasks
matters most produces a fluent opinion with no basis in anything, and a
confidently wrong priority is worse than none — same posture as the
behaviour profile in internal/memory, which counts instead of summarising.
internal/tasks is a pure package (no ipc, no store, no cgo) holding the
score, the order and the Russian rendering, so the spoken list and the
/tasks page cannot drift. Four signals, all of them things he stated:
deadline (overdue > today > tomorrow > this week), stated urgency, age
with a cap so nothing rots at the bottom, and confirmed work always
ahead of mail-derived candidates. A task with no due date and no weight
scores nothing and carries no reason string — inventing a "потому что"
about a priority he never set is the failure mode this avoids.
Capture now picks up urgency he says out loud ("добавь в задачи срочно
оплатить интернет"), stripping the marker from the task text, and the web
add form offers the same three rungs. Ranking is a read: it sorts and
renders, never writes, schedules or announces.
176 lines
6.7 KiB
Go
176 lines
6.7 KiB
Go
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
|
|
}
|