Address PR review comments on 50, 52, 53, 54, 59, 61

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
This commit is contained in:
kami
2026-08-01 12:50:47 +04:00
parent 927e46bca3
commit 7f42cc73be
17 changed files with 621 additions and 146 deletions
+8 -49
View File
@@ -13,30 +13,9 @@ import "strings"
// 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",
}
// 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
@@ -47,19 +26,6 @@ type TaskCapture struct {
Weight int
}
// urgencyMarkers — the words that set a weight, strongest first. Only these
// two rungs: "срочно" is a deadline he has not named, "важно" is a preference,
// and a third shade of urgent would be a distinction he never makes out loud.
var urgencyMarkers = []struct {
word string
weight int
}{
{"срочно", 3},
{"urgent", 3},
{"важно", 2},
{"important", 2},
}
// 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
@@ -101,11 +67,11 @@ 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:
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
}
@@ -113,13 +79,6 @@ func stripUrgency(text string) (string, int) {
return text, 0
}
// 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 — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
//
+59
View File
@@ -0,0 +1,59 @@
package router
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
)
// The task vocabulary lives in task_phrases.json, not in Go source. See the
// comment block inside that file for what each list means and why the entries
// that are NOT in it were left out.
//
// go:embed, so the single-binary deploy is unchanged: the JSON is compiled into
// mavend and there is nothing to install beside it. Parsed once at init; a
// malformed asset panics at startup rather than silently disabling task capture,
// which would look like the feature quietly not working.
//go:embed task_phrases.json
var taskPhrasesJSON []byte
type urgencyMarker struct {
Word string `json:"word"`
Weight int `json:"weight"`
}
type taskPhrases struct {
CapturePrefixes []string `json:"capture_prefixes"`
UrgencyMarkers []urgencyMarker `json:"urgency_markers"`
ListNouns []string `json:"list_nouns"`
ListShortcutNouns []string `json:"list_shortcut_nouns"`
}
var (
taskCapturePrefixes []string
urgencyMarkers []urgencyMarker
taskListWords []string
taskListWordsShortcut []string
)
func init() {
var v taskPhrases
if err := json.Unmarshal(taskPhrasesJSON, &v); err != nil {
panic(fmt.Sprintf("router: task_phrases.json: %v", err))
}
if len(v.CapturePrefixes) == 0 || len(v.ListNouns) == 0 {
panic("router: task_phrases.json: capture_prefixes and list_nouns must be non-empty")
}
// Strongest urgency first, so stripUrgency finds "срочно" before "важно"
// in an utterance carrying both. The file is written in that order already;
// sorting here means a careless edit cannot silently downgrade a task.
sort.SliceStable(v.UrgencyMarkers, func(i, j int) bool {
return v.UrgencyMarkers[i].Weight > v.UrgencyMarkers[j].Weight
})
taskCapturePrefixes = v.CapturePrefixes
urgencyMarkers = v.UrgencyMarkers
taskListWords = v.ListNouns
taskListWordsShortcut = v.ListShortcutNouns
}
+57
View File
@@ -0,0 +1,57 @@
{
"_comment": [
"The task capture and task-listing vocabulary. Embedded by task_phrases.go.",
"",
"These are lexicon, not logic: which words mean 'put this on the list' is a",
"fact about how he speaks, and it changes as he uses the thing. Keeping them",
"in Go meant every new phrasing was a source diff.",
"",
"capture_prefixes must be LEADING phrases. 'добавь в задачи купить молоко' is",
"a capture; 'я не добавил молоко в список' is him talking, and only position",
"tells them apart. There is deliberately no 'надо'/'нужно' entry: 'надо бы",
"поспать' is a mood, not a task, and guessing would fill the list with them.",
"",
"urgency markers carry the weight he stated out loud. Two rungs only:",
"'срочно' is a deadline he has not named and 'важно' is a preference. A third",
"shade would be a distinction he never makes.",
"",
"list_nouns are the nouns that make a question be about the list.",
"list_shortcut_nouns are the subset that stand alone as a whole utterance",
"('задачи'), which the longer nouns do not ('дел')."
],
"capture_prefixes": [
"добавь в задачи",
"добавь в список задач",
"добавь в список дел",
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши задачу",
"новая задача",
"в задачи",
"add a task",
"add task",
"add to my tasks",
"add to tasks",
"new task"
],
"urgency_markers": [
{ "word": "срочно", "weight": 3 },
{ "word": "urgent", "weight": 3 },
{ "word": "важно", "weight": 2 },
{ "word": "important", "weight": 2 }
],
"list_nouns": [
"задачи",
"задачах",
"задач",
"задачам",
"дела",
"делах",
"дел",
"tasks",
"todo",
"todos"
],
"list_shortcut_nouns": ["задачи", "задач", "tasks"]
}