708a69375f
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.
204 lines
7.9 KiB
Go
204 lines
7.9 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.
|
||
|
||
// 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
|
||
}
|