7f42cc73be
Seven fixes, each answering a line comment on the stack.
**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.
**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.
**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.
**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.
**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.
**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.
**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
135 lines
5.1 KiB
Go
135 lines
5.1 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)
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|