766ca091a7
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.
164 lines
5.1 KiB
Go
164 lines
5.1 KiB
Go
package pattern
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDetectEnoughEvents(t *testing.T) {
|
|
// MinEvents events with 7-day intervals → stable pattern
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want a proposed routine, got nil")
|
|
}
|
|
if r.Action != "refill" || r.Object != "cat_water" {
|
|
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
|
}
|
|
if r.N != 4 {
|
|
t.Fatalf("want N=4, got %d", r.N)
|
|
}
|
|
// ~7 days
|
|
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
|
t.Fatalf("want interval ~7, got %f", r.IntervalDays)
|
|
}
|
|
}
|
|
|
|
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
|
|
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
|
|
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
|
|
// before Maven calls it a pattern.
|
|
func TestDetectNotEnoughEvents(t *testing.T) {
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
for _, n := range []int{1, 2, MinEvents - 1} {
|
|
events := make([]Event, n)
|
|
for i := range events {
|
|
events[i] = Event{
|
|
Action: "refill",
|
|
Object: "cat_water",
|
|
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
|
}
|
|
}
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect(%d events): %v", n, err)
|
|
}
|
|
if r != nil {
|
|
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectEmpty(t *testing.T) {
|
|
r, err := Detect(nil)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for empty events")
|
|
}
|
|
|
|
r, err = Detect([]Event{})
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for empty events")
|
|
}
|
|
}
|
|
|
|
func TestDetectIrregularRejects(t *testing.T) {
|
|
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for irregular intervals (ratio 14 > 1.5)")
|
|
}
|
|
}
|
|
|
|
func TestDetectBarelyStable(t *testing.T) {
|
|
// 4 events, intervals vary but within 1.5 ratio
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "feed", Object: "cat", Ts: base},
|
|
{Action: "feed", Object: "cat", Ts: base.Add(6 * 24 * time.Hour)}, // 6 days
|
|
{Action: "feed", Object: "cat", Ts: base.Add(12 * 24 * time.Hour)}, // 6 days
|
|
{Action: "feed", Object: "cat", Ts: base.Add(20 * 24 * time.Hour)}, // 8 days
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want proposed routine for barely stable intervals (8/6=1.33 ≤ 1.5)")
|
|
}
|
|
if r.Action != "feed" || r.Object != "cat" {
|
|
t.Fatalf("action/object mismatch")
|
|
}
|
|
if r.N != 4 {
|
|
t.Fatalf("want N=4, got %d", r.N)
|
|
}
|
|
}
|
|
|
|
func TestDetectSameTimestamp(t *testing.T) {
|
|
// Two events at the same time — meaningless interval, should be ignored
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil when first two events have same timestamp")
|
|
}
|
|
}
|
|
|
|
func TestPhraseRoutine(t *testing.T) {
|
|
tests := []struct {
|
|
r ProposedRoutine
|
|
want string
|
|
}{
|
|
{ProposedRoutine{Action: "refill", Object: "cat_water", IntervalDays: 7}, "ты заправляешь cat water раз в неделю — напоминать?"},
|
|
{ProposedRoutine{Action: "feed", Object: "cat", IntervalDays: 1}, "ты кормишь cat каждый день — напоминать?"},
|
|
{ProposedRoutine{Action: "clean", Object: "litter_box", IntervalDays: 3}, "ты чистишь litter box раз в 3 дня — напоминать?"},
|
|
{ProposedRoutine{Action: "take", Object: "medicine", IntervalDays: 0.5}, "ты принимаешь medicine каждый день — напоминать?"},
|
|
{ProposedRoutine{Action: "walk", Object: "dog", IntervalDays: 14}, "ты выгуливаешь dog раз в 2 недели — напоминать?"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.r.Action+"_"+tc.r.Object, func(t *testing.T) {
|
|
got := PhraseRoutine(&tc.r)
|
|
if got != tc.want {
|
|
t.Fatalf("phrase: want %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|