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.
This commit is contained in:
+86
-17
@@ -47,7 +47,10 @@ func ParseTaskCapture(text string) (TaskCapture, bool) {
|
||||
rest := strings.TrimSpace(trimmed[len(best):])
|
||||
rest = strings.TrimLeft(rest, ":—- ")
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.TrimRight(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
|
||||
@@ -55,30 +58,86 @@ func ParseTaskCapture(text string) (TaskCapture, bool) {
|
||||
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 {
|
||||
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
|
||||
// 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 — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
|
||||
//
|
||||
@@ -94,13 +153,23 @@ func IsTaskListQuery(text string) bool {
|
||||
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
|
||||
// "что мне нужно сделать" / "чем мне заняться" — 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 {
|
||||
|
||||
Reference in New Issue
Block a user