Files
Maven/internal/pattern/detector.go
T
kami 766ca091a7 Announce tick-inferred routines, opt-in and rate-limited (#247, #43)
The digestion tick already runs the pattern detector over all recorded
events (67563ed) and writes a proposed_routines row. What was missing is
the other half of #247: a proposal that nobody is at the mic for reaches
nothing but the /routines page, so a pattern noticed at 03:00 is only
seen if he goes looking.

This wires the tick's proposals into the existing care-delivery path
rather than a second channel: sev1 nudge, loop.Gate, dispatcher, same
routing table as an accepted routine. Restraints, since a feature that
speaks unprompted is the easiest way to turn Maven into a nag:

  - off unless configured — the new pattern_proposals block, absent by
    default, and deploy/mavend.json ships notify: false;
  - at most one announcement per tick however many patterns surfaced;
  - at most one per cooldown (24h default) across all pairs;
  - sev1, so quiet hours, away and snooze suppress it;
  - suppressed means dropped, not queued — /routines still has it;
  - once per pair for good, since proposed_routines is
    UNIQUE(action, object) and the row survives dismissal.

The body is pattern.PhraseRoutine's literal Russian, not LLM-worded, so
an inferred routine cannot arrive describing something never observed.

Also raises pattern.MinEvents from 3 to 4 — the interval-quality item on
#43. Two intervals with a ±50% band is a coincidence with a mean, not a
pattern, and now that a scan of all history can announce itself the cost
of a false positive is a permanent dismissal of that pair.
2026-08-01 01:37:50 +04:00

151 lines
4.9 KiB
Go

package pattern
import (
"fmt"
"math"
"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 // mean interval in days (float for sub-day precision)
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.
const MaxIntervalRatio = 1.5
// 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 (≥2 intervals)
// - The ratio longest/shortest interval ≤ MaxIntervalRatio
//
// 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)
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
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
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 {
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
N: len(events),
}, nil
}
// 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 + " (делаешь)"
}
}