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
+5
View File
@@ -325,6 +325,11 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
return "", false
}
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
if loc == "" {
// He named no city and voice.weather.default_location is unset. Saying
// so is the only honest answer; picking a city would be inventing one.
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
}
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
+61 -6
View File
@@ -110,11 +110,18 @@ func quietPhrase(tokens, pattern []string) bool {
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
//
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
// Negation is handled by quietNegators below, over the whole utterance.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
{"отмен", "тих"}, {"выключ", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
@@ -123,11 +130,44 @@ var (
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
// quietNegatorWords — negators that are whole words with no useful stem.
var quietNegatorWords = map[string]bool{
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
}
// quietNegatorStems — negators that inflect. Matched through quietStem, the
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
// and "выключай" all count and "выключатель" does not.
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
// quietNegated reports whether the utterance carries a negator. Two ON phrases
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
// requests FOR quiet, so they are excluded before the scan: a negator only
// counts when it is not part of the phrase that matched.
func quietNegated(tokens []string, matched []string) bool {
if len(matched) > 0 && matched[0] == "не" {
return false
}
for _, t := range tokens {
if quietNegatorWords[t] {
return true
}
for _, stem := range quietNegatorStems {
if quietStem(t, stem) {
return true
}
}
}
return false
}
// classifyQuietToggle reads an utterance as a quiet-mode command.
//
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
// checks negatives first: they are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
// matches is then checked for negation across the whole utterance, so any way
// of saying "not quiet mode" turns it off rather than on.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
@@ -137,8 +177,23 @@ func classifyQuietToggle(text string) (on, off bool) {
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
if quietNegated(tokens, p) {
return false, true
}
return true, false
}
}
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
// matches a one-token utterance, by design, so the negator pushes the token
// count past it — and reading them as "no command" would leave quiet mode
// on after he asked for it to stop.
if quietNegated(tokens, nil) {
for _, t := range tokens {
if quietStem(t, "тих") {
return false, true
}
}
}
return false, false
}
+35
View File
@@ -112,3 +112,38 @@ func TestResolveQuietToggle(t *testing.T) {
})
}
}
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
// the quiet word made the ON pattern win and asking for quiet mode to STOP
// turned it on. Negation is scanned over the whole utterance now.
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
off := []string{
"не надо тихий режим",
"не хочу тихий режим",
"тихий режим выключи",
"убери тихий режим",
"хватит тихого режима",
"прекрати тихий режим",
"тихий режим отмени пожалуйста",
}
for _, text := range off {
t.Run(text, func(t *testing.T) {
on, isOff := classifyQuietToggle(text)
if on || !isOff {
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
}
})
}
// The two ON phrases that are themselves built on "не" must stay ON: they
// are requests FOR quiet, not negations of one.
for _, text := range []string{"не шуми", "не беспокоить"} {
t.Run(text, func(t *testing.T) {
on, isOff := classifyQuietToggle(text)
if !on || isOff {
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
}
})
}
}
+30 -22
View File
@@ -19,32 +19,40 @@ func isWeatherQuery(u string) bool {
strings.Contains(lower, "temperature")
}
// extractWeatherLocation parses a location from the utterance, or falls back
// to the configured default. Very basic: just checks for known city names.
// weatherCities — the city names an utterance may name explicitly, as
// lowercase substrings mapped to the provider's spelling. This is a
// convenience for "какая погода в Лондоне", NOT a source of default truth:
// nothing here is used unless he actually said it.
var weatherCities = map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"нью-йорк": "New York",
"new york": "New York",
}
// extractWeatherLocation returns the city he named, or the configured default
// when he named none. It returns "" when he named none AND no default is
// configured — the caller must then say it does not know.
//
// It used to return "Moscow" in that case. That is a made-up answer presented
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
// wrong in exactly the way maven must never be wrong. voice.weather
// .default_location is the only source of an unstated location.
func extractWeatherLocation(u, defaultLoc string) string {
lower := strings.ToLower(u)
cities := map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"нью-йорк": "New York",
"new york": "New York",
}
for substr, name := range cities {
for substr, name := range weatherCities {
if strings.Contains(lower, substr) {
return name
}
}
if defaultLoc != "" {
return defaultLoc
}
return "Moscow"
return defaultLoc
}