7b2b96b957
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
129 lines
4.8 KiB
Go
129 lines
4.8 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",
|
|
}
|
|
|
|
// 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) (string, 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 "", 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, ".!")
|
|
if rest == "" {
|
|
return "", false
|
|
}
|
|
return rest, true
|
|
}
|
|
|
|
// 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
|
|
}
|