Files
Maven/internal/router/task.go
kami 708a69375f tasks: key derived captures by external id and record who resolved
A task extracted from mail deduped on the live-norm index only, so once he
finished it the row left the live set and the next poll of the same immutable
message re-extracted it as a fresh candidate. mavmaild is a read-only reader
and marks nothing read, so that repeats forever. Derived rows now carry an
ext_id built from the message uid and the extracted span, unique across every
status, while voice keeps live-only norm dedupe because saying an errand again
is the recurrence signal. A derived source can no longer capture straight to
open, and saying a task out loud that Maven had only proposed promotes the
candidate instead of answering that it is already in the list.

SetTaskStatus was classified AuthRead. Resolving a task is not additive, it
erases work off his list, so it is a write, and the row now records the caller
that moved it. ListTasks was unbounded. The list-query matcher claimed any
utterance with "что мне делать", including "с чем мне помочь", and the urgency
stripper matched inside words.

Found in review of #60.
2026-08-01 14:16:39 +04:00

204 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// 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
}