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
191 lines
6.8 KiB
Go
191 lines
6.8 KiB
Go
package pattern
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ProposedRoutine is a detected recurring pattern that the system wants to
|
|
// suggest as a reminder. Returned by Detect when intervals are stable.
|
|
type ProposedRoutine struct {
|
|
Action string
|
|
Object string
|
|
IntervalDays float64 // median of the on-pattern intervals, in days
|
|
N int // number of events used
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// This used to be 3 (two intervals), which is not a pattern — it is a
|
|
// coincidence with a mean. Two gaps of similar length happen constantly:
|
|
// water the plants on a Sunday, again the next Sunday, once more the Sunday
|
|
// after, and a detector with a ±50% band calls that a weekly routine. The
|
|
// cost of being wrong is asymmetric now that the digestion tick scans all of
|
|
// history on its own schedule and can announce what it finds: a false
|
|
// positive is something the owner has to read and dismiss, and a dismissal
|
|
// is permanent, so one bad guess burns that action+object pair forever.
|
|
// Three intervals is the cheapest bar that makes a run distinguishable from
|
|
// a repeat. False negatives cost one more observation and nothing else.
|
|
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 (≥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
|
|
// is a false positive, and this detector makes none: the confirmation
|
|
// gate (voice park or web page) catches any we do produce.
|
|
func Detect(events []Event) (*ProposedRoutine, error) {
|
|
if len(events) < MinEvents {
|
|
return nil, nil // not enough data
|
|
}
|
|
|
|
nIntervals := len(events) - 1
|
|
intervals := make([]float64, nIntervals)
|
|
|
|
for i := 0; i < nIntervals; i++ {
|
|
diff := events[i+1].Ts.Sub(events[i].Ts)
|
|
days := diff.Hours() / 24.0
|
|
if days <= 0 {
|
|
// Two events at the same timestamp — can't compute a meaningful
|
|
// interval. Skip this candidate silently.
|
|
return nil, nil
|
|
}
|
|
intervals[i] = days
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return &ProposedRoutine{
|
|
Action: events[0].Action,
|
|
Object: events[0].Object,
|
|
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 дней — напоминать?"
|
|
func PhraseRoutine(p *ProposedRoutine) string {
|
|
actionWord := p.Action
|
|
objectWord := p.Object
|
|
|
|
days := int(math.Round(p.IntervalDays))
|
|
// Russian grammatical gender/hardcoded — matches maven's existing persona.
|
|
var intervalPhrase string
|
|
switch {
|
|
case days < 1:
|
|
intervalPhrase = "каждый день"
|
|
case days == 1:
|
|
intervalPhrase = "каждый день"
|
|
case days < 7:
|
|
intervalPhrase = fmt.Sprintf("раз в %d дня", days)
|
|
if days%10 == 1 && days%100 != 11 {
|
|
intervalPhrase = fmt.Sprintf("раз в %d день", days)
|
|
}
|
|
case days == 7:
|
|
intervalPhrase = "раз в неделю"
|
|
case days%7 == 0:
|
|
intervalPhrase = fmt.Sprintf("раз в %d недели", days/7)
|
|
if (days/7)%10 == 1 && (days/7)%100 != 11 {
|
|
intervalPhrase = fmt.Sprintf("раз в %d неделю", days/7)
|
|
}
|
|
case days < 30:
|
|
intervalPhrase = fmt.Sprintf("раз в %d дней", days)
|
|
default:
|
|
intervalPhrase = fmt.Sprintf("каждые %d дней", days)
|
|
}
|
|
|
|
objectDisplay := strings.ReplaceAll(objectWord, "_", " ")
|
|
return fmt.Sprintf("ты %s %s %s — напоминать?", actionVerb(actionWord), objectDisplay, intervalPhrase)
|
|
}
|
|
|
|
// actionVerb returns a conjugated Russian verb form for "you do" (ты-form).
|
|
func actionVerb(action string) string {
|
|
switch action {
|
|
case "refill":
|
|
return "заправляешь"
|
|
case "feed":
|
|
return "кормишь"
|
|
case "change":
|
|
return "меняешь"
|
|
case "clean":
|
|
return "чистишь"
|
|
case "take":
|
|
return "принимаешь"
|
|
case "walk":
|
|
return "выгуливаешь"
|
|
case "water":
|
|
return "поливаешь"
|
|
default:
|
|
return action + " (делаешь)"
|
|
}
|
|
}
|