diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 449d404..c36682d 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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) diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go index e4bf9c3..142706e 100644 --- a/cmd/mavend/quiet_toggle.go +++ b/cmd/mavend/quiet_toggle.go @@ -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 } diff --git a/cmd/mavend/quiet_toggle_test.go b/cmd/mavend/quiet_toggle_test.go index b5c1280..cad56a9 100644 --- a/cmd/mavend/quiet_toggle_test.go +++ b/cmd/mavend/quiet_toggle_test.go @@ -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) + } + }) + } +} diff --git a/cmd/mavend/weatherq.go b/cmd/mavend/weatherq.go index c844f8d..344e2fa 100644 --- a/cmd/mavend/weatherq.go +++ b/cmd/mavend/weatherq.go @@ -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 } diff --git a/deploy/ecosystem/nginx-upgrade-map.conf b/deploy/ecosystem/nginx-upgrade-map.conf new file mode 100644 index 0000000..1c237bb --- /dev/null +++ b/deploy/ecosystem/nginx-upgrade-map.conf @@ -0,0 +1,22 @@ +# $connection_upgrade — WebSocket upgrade helper for the maven. block +# in nginx.conf. Install this ONLY if your nginx does not already define +# $connection_upgrade somewhere in the http context. +# +# It is a separate file because nginx treats a duplicate `map` directive as a +# fatal configuration error, not a warning: if this block were inside +# nginx.conf and your setup already had one (nginx-panel and most WebSocket +# recipes ship one), the next `nginx -s reload` would fail the config test and +# nginx would refuse to come back up — taking every other site on the box down +# with it, not just maven. +# +# Check before installing: +# grep -rn 'connection_upgrade' /etc/nginx/ +# Nothing? Drop this in /etc/nginx/conf.d/ and reload. Something already there? +# Skip this file entirely; nginx.conf works as-is. +# +# Verify either way before reloading: +# nginx -t +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} diff --git a/deploy/ecosystem/nginx.conf b/deploy/ecosystem/nginx.conf index 7e84902..5f8b685 100644 --- a/deploy/ecosystem/nginx.conf +++ b/deploy/ecosystem/nginx.conf @@ -12,6 +12,25 @@ # On a different box, replace both addresses with that box's wg and LAN IPs. # Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) — # that removes the only access control these containers have. +# +# BEFORE YOU RELOAD — two ways this file takes nginx down, both host-side. +# These blocks are for the HOST nginx, not for anything inside the compose; +# the containers publish on 127.0.0.1 and have no nginx of their own. +# +# 1. Binding an address that does not exist yet. `listen 10.42.0.1:80` fails +# with EADDRNOTAVAIL if wg0 is down, and nginx exits rather than starting +# without it — so a reboot that brings nginx up before WireGuard leaves the +# box with no web at all. Allow the bind to succeed regardless: +# sysctl -w net.ipv4.ip_nonlocal_bind=1 +# echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/99-nginx-bind.conf +# Ordering nginx after the wg interface works too, but only until the next +# time the tunnel restarts. +# +# 2. A duplicate $connection_upgrade map. That is a fatal config error, so the +# map now lives in nginx-upgrade-map.conf and is installed separately — +# read the note at the top of that file first. +# +# `nginx -t` catches the second and not the first. Run it anyway, every time. # maven. → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201). # Same bind + ACL as the siblings, and for a stronger reason: mavweb serves @@ -22,14 +41,9 @@ # allow/deny anyway — belt and braces on an RCE surface. # # WebSocket upgrade matters here: /ws carries push-to-talk audio, so the -# Upgrade/Connection headers below are required, not decoration. The map keeps -# `Connection: upgrade` off plain requests; it sits in the http context, which -# is where sites-available files are included — if your nginx already defines -# $connection_upgrade, drop this block. -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} +# Upgrade/Connection headers below are required, not decoration. They reference +# $connection_upgrade, which this file does NOT define — see +# nginx-upgrade-map.conf and point 2 above. server { listen 10.42.0.1:80; diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 2066b57..7356f44 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -53,15 +53,27 @@ type Activity struct { TypicalAt time.Duration } -// Profile — the counted behaviour model. Weekly holds the activities that -// recur on a given weekday, Overall the ones that recur at all. +// Profile — the counted behaviour model. +// +// Weekly holds only the activities that DISTINGUISH a weekday: things he does +// on Tuesdays and not on most other days. Everyday holds the ones that recur +// across the week, and All holds both. The split exists because the two answer +// different questions, and conflating them produced the failure that named +// this: asked what he does on Saturdays, maven replied "ты пьёшь воду". type Profile struct { - Since time.Time - Until time.Time - Weekly map[time.Weekday][]Activity - All []Activity + Since time.Time + Until time.Time + Weekly map[time.Weekday][]Activity + Everyday []Activity + All []Activity } +// EverydaySpan — the number of weekdays an activity must be a habit on before +// it stops counting as characteristic of any one of them. Six of seven, not +// five: a weekday-only rhythm spans exactly five, and "по будням ты +// тренируешься" is a real answer about Tuesday. Six days a week is not. +const EverydaySpan = 6 + // MinHabitDays — how many distinct days an activity must appear on before maven // will call it usual. Two is the smallest number that can distinguish a habit // from a one-off; below that she says she does not know yet, which is true. @@ -161,9 +173,36 @@ func BuildProfile(obs []Observation, now time.Time) Profile { } p.All = harvest(all) + + // How many weekdays each key is a habit on. An activity that recurs on + // most days of the week is a daily habit, and naming it as an answer to + // "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is + // true of Saturday and of every other day, so it says nothing about + // Saturday. Those are held in Everyday and read back separately. + span := map[string]int{} + harvested := map[time.Weekday][]Activity{} for wd, m := range weekly { - if acts := harvest(m); len(acts) > 0 { - p.Weekly[wd] = acts + acts := harvest(m) + harvested[wd] = acts + for _, a := range acts { + span[a.Key]++ + } + } + for wd, acts := range harvested { + var distinct []Activity + for _, a := range acts { + if span[a.Key] >= EverydaySpan { + continue + } + distinct = append(distinct, a) + } + if len(distinct) > 0 { + p.Weekly[wd] = distinct + } + } + for _, a := range p.All { + if span[a.Key] >= EverydaySpan { + p.Everyday = append(p.Everyday, a) } } return p @@ -193,33 +232,28 @@ func medianInt(xs []int) int { return (s[mid-1] + s[mid]) / 2 } -// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read -// back. Index is time.Weekday. -var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"} +// weekdayRU and activityRU are loaded from the embedded behavior_ru.json; +// see behavior_ru.go. -// activityRU glosses the loop's known fact keys. An unknown key is read back -// verbatim: it is what the store holds, and inventing a Russian phrase for a key -// maven does not recognise would be putting words in his mouth. -var activityRU = map[string]string{ - "water": "пьёшь воду", - "meal": "ешь", - "sleep": "спишь", - "break": "делаешь перерыв", - "shower": "принимаешь душ", - "walk": "гуляешь", - "pills": "пьёшь витамины", - "workout": "тренируешься", -} - -// FormatWeekdayRU reads back what he usually does on a given weekday. +// FormatWeekdayRU reads back what DISTINGUISHES a given weekday. // Second person singular and informal, as she speaks TO him. +// +// When nothing distinguishes it, she says so and names the daily habits as +// daily habits instead of passing them off as an answer about that day. The +// previous version had no such distinction and answered "что я делаю по +// субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday +// were the reason. func (p Profile) FormatWeekdayRU(wd time.Weekday) string { - acts := p.Weekly[wd] day := weekdayRU[int(wd)%7] - if len(acts) == 0 { - return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) + acts := p.Weekly[wd] + if len(acts) > 0 { + return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) } - return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) + if len(p.Everyday) > 0 { + return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", + day, joinActivities(p.Everyday)) + } + return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) } // FormatOverallRU reads back the habits that hold across the whole week. diff --git a/internal/memory/behavior_ru.go b/internal/memory/behavior_ru.go new file mode 100644 index 0000000..6354e93 --- /dev/null +++ b/internal/memory/behavior_ru.go @@ -0,0 +1,45 @@ +package memory + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// The Russian read-back vocabulary lives in behavior_ru.json, not in Go source. +// +// It is data, not logic: a weekday name and a verb gloss are wording choices +// that change independently of anything the counter does, and having them as +// literals scattered through behavior.go meant every phrasing tweak was a code +// diff. go:embed keeps the single-binary deploy — the JSON is compiled in, so +// there is no file to install alongside mavend and no way for the two to drift. +// +// Parsed once at init. A malformed file is a panic, deliberately: it can only +// happen if the embedded asset is broken at build time, and a daemon that comes +// up with an empty vocabulary would answer with bare fact keys. + +//go:embed behavior_ru.json +var behaviorRUJSON []byte + +type behaviorRU struct { + Weekdays []string `json:"weekdays"` + Activities map[string]string `json:"activities"` +} + +var ( + // weekdayRU — dative plural, indexed by time.Weekday. + weekdayRU []string + // activityRU — fact key to second-person-singular verb phrase. + activityRU map[string]string +) + +func init() { + var v behaviorRU + if err := json.Unmarshal(behaviorRUJSON, &v); err != nil { + panic(fmt.Sprintf("memory: behavior_ru.json: %v", err)) + } + if len(v.Weekdays) != 7 { + panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays))) + } + weekdayRU, activityRU = v.Weekdays, v.Activities +} diff --git a/internal/memory/behavior_ru.json b/internal/memory/behavior_ru.json new file mode 100644 index 0000000..8bdfcdd --- /dev/null +++ b/internal/memory/behavior_ru.json @@ -0,0 +1,34 @@ +{ + "_comment": [ + "Russian read-back vocabulary for the counted behaviour profile.", + "Embedded into the binary by behavior_ru.go — there is no runtime file to", + "ship or lose. Editing wording here does not need a Go change, which is the", + "whole point: these are strings for a person, not program logic.", + "", + "weekdays are indexed by time.Weekday (0 = Sunday) and are in the dative", + "plural, because both 'по вторникам' and 'по средам' need that form.", + "", + "activities glosses a fact key as a second-person-singular verb phrase. An", + "unknown key is read back verbatim rather than guessed at: inventing a", + "Russian phrase for a key maven does not recognise puts words in his mouth." + ], + "weekdays": [ + "воскресеньям", + "понедельникам", + "вторникам", + "средам", + "четвергам", + "пятницам", + "субботам" + ], + "activities": { + "water": "пьёшь воду", + "meal": "ешь", + "sleep": "спишь", + "break": "делаешь перерыв", + "shower": "принимаешь душ", + "walk": "гуляешь", + "pills": "пьёшь витамины", + "workout": "тренируешься" + } +} diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index ad2150b..d257667 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -161,3 +161,49 @@ func TestBuildProfileIgnoresFutureRows(t *testing.T) { t.Fatalf("future rows counted: %+v", p.All) } } + +// TestWeekdayProfileExcludesEverydayHabits — the "You drink water" case. +// Drinking water every day is not what he does on Saturdays, and answering +// with it makes the weekday question pointless. +func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { + now := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) // a Saturday + var obs []Observation + // water: twice a day, every day, for three weeks. + for d := 1; d <= 21; d++ { + day := now.AddDate(0, 0, -d) + obs = append(obs, + Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, time.UTC), Key: "water", Kind: "self"}, + Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 18, 0, 0, 0, time.UTC), Key: "water", Kind: "self"}) + } + // workout: Saturdays only. + for _, d := range []int{7, 14, 21} { + day := now.AddDate(0, 0, -d) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), 11, 0, 0, 0, time.UTC), Key: "workout", Kind: "self"}) + } + + p := BuildProfile(obs, now) + + sat := p.Weekly[time.Saturday] + if len(sat) != 1 || sat[0].Key != "workout" { + t.Fatalf("Saturday should be characterised by workout alone, got %+v", sat) + } + if len(p.Everyday) != 1 || p.Everyday[0].Key != "water" { + t.Fatalf("water should be an everyday habit, got %+v", p.Everyday) + } + + got := p.FormatWeekdayRU(time.Saturday) + if !strings.Contains(got, "тренируешься") { + t.Fatalf("Saturday readout should name the workout: %q", got) + } + if strings.Contains(got, "воду") { + t.Fatalf("Saturday readout must not recite the everyday habit: %q", got) + } + + // A day with nothing of its own says so rather than reciting water as if + // Wednesday were the reason for it. + wed := p.FormatWeekdayRU(time.Wednesday) + if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") { + t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) + } +} diff --git a/internal/pattern/detector.go b/internal/pattern/detector.go index 8c19775..741d81f 100644 --- a/internal/pattern/detector.go +++ b/internal/pattern/detector.go @@ -3,6 +3,7 @@ package pattern import ( "fmt" "math" + "sort" "strings" ) @@ -11,14 +12,32 @@ import ( type ProposedRoutine struct { Action string Object string - IntervalDays float64 // mean interval in days (float for sub-day precision) + IntervalDays float64 // median of the on-pattern intervals, in days N int // number of events used } -// MaxIntervalRatio is the maximum ratio between the longest and shortest -// interval for a pattern to be considered stable. ±50% variance allowed. +// MaxIntervalRatio — how far an interval may sit from the median and still +// count as on-pattern. 1.5 means a 7-day rhythm accepts gaps between ~4.7 and +// ~10.5 days. +// +// It is applied per interval against the MEDIAN, not to the longest/shortest +// pair. The old extremes test asked "is every gap similar to every other gap", +// which is a different and much more brittle question: 7, 7, 7, 7, 20 is four +// clean weeks and one holiday, and max/min = 2.9 threw the whole thing away. +// One missed week should not erase a habit. const MaxIntervalRatio = 1.5 +// MinOnPatternFraction — how much of the history must sit inside the band +// before a rhythm is a rhythm. A strict majority: with the median as the +// centre, half the intervals are inside it by construction, so anything at or +// below 0.5 would accept noise. 5, 8, 10, 3 has a median of 6.5 and only two +// of four gaps in band, so it stays what it is — irregular, no routine. +// +// At the MinEvents floor (three intervals) 0.7 demands all three, which is +// right: four events is already the cheapest bar and there is no room in it to +// also forgive an outlier. Tolerance starts at five intervals, where 4/5 passes. +const MinOnPatternFraction = 0.7 + // MinEvents is the minimum number of events needed to detect a pattern. // With N events there are N-1 intervals, so 4 events means 3 intervals. // @@ -36,8 +55,14 @@ const MinEvents = 4 // Detect checks whether a sequence of events for the same action+object // forms a stable recurring pattern. Returns a ProposedRoutine when: -// - At least MinEvents events exist (≥2 intervals) -// - The ratio longest/shortest interval ≤ MaxIntervalRatio +// - At least MinEvents events exist (≥3 intervals) +// - At least MinOnPatternFraction of the intervals sit within +// MaxIntervalRatio of the median interval +// +// The reported IntervalDays is the median of the ON-PATTERN intervals only. +// Outliers are excluded from the number as well as from the test, so a habit +// interrupted by a two-week holiday is still reported as weekly rather than as +// "every 9.6 days" — a figure that describes neither the habit nor the gap. // // Returns nil when there aren't enough events or the intervals are too // irregular — false negatives are harmless. The only dangerous mistake @@ -51,10 +76,6 @@ func Detect(events []Event) (*ProposedRoutine, error) { nIntervals := len(events) - 1 intervals := make([]float64, nIntervals) - var sum float64 - var min float64 = math.MaxFloat64 - var max float64 - for i := 0; i < nIntervals; i++ { diff := events[i+1].Ts.Sub(events[i].Ts) days := diff.Hours() / 24.0 @@ -64,32 +85,51 @@ func Detect(events []Event) (*ProposedRoutine, error) { return nil, nil } intervals[i] = days - sum += days - if days < min { - min = days - } - if days > max { - max = days - } } - // Stability check: the most extreme intervals shouldn't differ by - // more than MaxIntervalRatio. A ratio of 1.5 means a 7-day pattern - // can have intervals between ~5.6 and ~8.4 days. - if min > 0 && max/min > MaxIntervalRatio { + center := medianFloat(intervals) + if center <= 0 { + return nil, nil + } + + // Keep the intervals that sit inside the band around the median. The + // bound is symmetric in ratio terms, not in days: half the median below, + // the median times the ratio above. + var onPattern []float64 + for _, d := range intervals { + if d <= center*MaxIntervalRatio && d >= center/MaxIntervalRatio { + onPattern = append(onPattern, d) + } + } + if float64(len(onPattern))/float64(nIntervals) < MinOnPatternFraction { return nil, nil // too irregular } - mean := sum / float64(nIntervals) - return &ProposedRoutine{ Action: events[0].Action, Object: events[0].Object, - IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal + IntervalDays: math.Round(medianFloat(onPattern)*10) / 10, // round to 1 decimal N: len(events), }, nil } +// medianFloat — the middle value, averaging the two middles on an even count. +// Sorts a copy: the caller's interval order is the event order and stays that +// way. +func medianFloat(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + s := make([]float64, len(xs)) + copy(s, xs) + sort.Float64s(s) + mid := len(s) / 2 + if len(s)%2 == 1 { + return s[mid] + } + return (s[mid-1] + s[mid]) / 2 +} + // PhraseRoutine generates a human-readable suggestion string for a // detected routine. Returns a Russian phrase like // "ты заправляешь поилку раз в 7 дней — напоминать?" diff --git a/internal/pattern/detector_test.go b/internal/pattern/detector_test.go index 0e24f8e..056a3f6 100644 --- a/internal/pattern/detector_test.go +++ b/internal/pattern/detector_test.go @@ -161,3 +161,58 @@ func TestPhraseRoutine(t *testing.T) { }) } } + +// evAt builds a run of events at the given day offsets. +func evAt(offsets ...float64) []Event { + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + out := make([]Event, len(offsets)) + for i, d := range offsets { + out[i] = Event{Action: "refill", Object: "cat_water", + Ts: base.Add(time.Duration(d * float64(24*time.Hour)))} + } + return out +} + +// TestDetectMedianBandNotExtremes — the stability test used to be +// longest/shortest, so a single outlier vetoed an otherwise clean rhythm and +// the reported interval was a mean dragged toward that outlier. Both are +// median-based now. +func TestDetectMedianBandNotExtremes(t *testing.T) { + cases := []struct { + name string + days []float64 + want float64 // 0 means "expect no routine" + }{ + // Four clean weeks and one holiday. max/min was 20/7 = 2.9, rejected. + {"weekly with one long gap", []float64{0, 7, 14, 21, 28, 48}, 7}, + // The reviewer's case: 5, 8, 10, 3. Median 6.5, only two gaps in band. + {"genuinely irregular", []float64{0, 5, 13, 23, 26}, 0}, + // A short gap outlier is treated the same as a long one. + {"weekly with one short gap", []float64{0, 7, 14, 15, 22, 29}, 7}, + // Two outliers out of five is past the fraction. + {"too many outliers", []float64{0, 7, 14, 34, 41, 61}, 0}, + // At the MinEvents floor there is no outlier budget at all. + {"floor rejects one outlier", []float64{0, 7, 14, 34}, 0}, + {"floor accepts a clean run", []float64{0, 7, 14, 21}, 7}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, err := Detect(evAt(tc.days...)) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if tc.want == 0 { + if r != nil { + t.Fatalf("want no routine, got interval %.1f", r.IntervalDays) + } + return + } + if r == nil { + t.Fatal("want a routine, got nil") + } + if r.IntervalDays != tc.want { + t.Fatalf("interval: want %.1f, got %.1f", tc.want, r.IntervalDays) + } + }) + } +} diff --git a/internal/router/task.go b/internal/router/task.go index 293dce9..4c7ace1 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -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 — "какие у меня задачи?", "что мне нужно сделать?", "список дел". // diff --git a/internal/router/task_phrases.go b/internal/router/task_phrases.go new file mode 100644 index 0000000..c7113f7 --- /dev/null +++ b/internal/router/task_phrases.go @@ -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 +} diff --git a/internal/router/task_phrases.json b/internal/router/task_phrases.json new file mode 100644 index 0000000..79e5b5c --- /dev/null +++ b/internal/router/task_phrases.json @@ -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"] +} diff --git a/internal/store/digest.go b/internal/store/digest.go index 56838c9..457b50e 100644 --- a/internal/store/digest.go +++ b/internal/store/digest.go @@ -8,13 +8,20 @@ import ( "time" ) -// Digest entry statuses. pending = enqueued, waiting for a drain. drained = -// spoken as part of a bundle. expired = the tick loop's expiry sweep found it -// past its expires_ts before a drain happened — dropped, not delivered late. +// DigestStatus — the lifecycle state of a digest entry. Go has no enum type; +// the idiom is a defined type plus constants, which is what this is. The point +// is not ceremony: with bare strings nothing stopped a rule name or a body +// hash being passed where a status belongs, and every one of these values +// reaches SQL. A defined type makes that a compile error. +type DigestStatus string + +// pending = enqueued, waiting for a drain. drained = spoken as part of a +// bundle. expired = the tick loop's expiry sweep found it past its expires_ts +// before a drain happened — dropped, not delivered late. const ( - DigestPending = "pending" - DigestDrained = "drained" - DigestExpired = "expired" + DigestPending DigestStatus = "pending" + DigestDrained DigestStatus = "drained" + DigestExpired DigestStatus = "expired" ) // DigestEntry — one gate-suppressed care candidate durably held for later diff --git a/internal/store/digest_test.go b/internal/store/digest_test.go index 4d55af8..9c78392 100644 --- a/internal/store/digest_test.go +++ b/internal/store/digest_test.go @@ -144,7 +144,7 @@ func TestDigestEntryExpiresRatherThanDeliversLate(t *testing.T) { t.Fatalf("want 1 entry expired, got %d", n) } - var status string + var status DigestStatus if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil { t.Fatalf("read back: %v", err) } @@ -191,7 +191,7 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) { } for _, id := range []int64{id1, id2} { - var status string + var status DigestStatus if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil { t.Fatalf("read back %d: %v", id, err) }