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.
This commit is contained in:
@@ -140,6 +140,12 @@ type Config struct {
|
||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
||||
|
||||
// PatternProposals — whether a routine the digestion tick inferred on its
|
||||
// own may be announced, and how often. nil / absent ⇒ silent detection
|
||||
// only: proposals are written for /routines and never announced. See
|
||||
// PatternProposalConfig.
|
||||
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
|
||||
|
||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||
@@ -352,6 +358,40 @@ type DigestConfig struct {
|
||||
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
||||
}
|
||||
|
||||
// PatternProposalConfig — announcement policy for routines the digestion tick
|
||||
// inferred by itself (Vikunja #247, #43).
|
||||
//
|
||||
// Detection is always on and always silent by default: the tick writes a
|
||||
// proposed_routines row and the /routines page shows it. Notify is what turns
|
||||
// "she noticed" into "she said something", and it is OFF unless configured —
|
||||
// Maven is not a nag and not autonomous, so a behaviour that speaks without
|
||||
// being asked has to be switched on deliberately, like weather and telegram.
|
||||
//
|
||||
// When Notify is on, the announcement is still heavily restrained:
|
||||
// - at most one proposal per tick, however many were detected;
|
||||
// - at most one per Cooldown across all pairs (not per pair), so a batch of
|
||||
// freshly-detected patterns cannot turn into a queue of interruptions;
|
||||
// - through the ordinary care-class gate (quiet hours / away / snooze), at
|
||||
// sev1 — the lowest severity there is. A proposal is the least urgent
|
||||
// thing Maven can say.
|
||||
//
|
||||
// A pair is only ever announced once, because it is only ever proposed once:
|
||||
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
|
||||
type PatternProposalConfig struct {
|
||||
// Notify — announce newly inferred routines. Default false.
|
||||
Notify bool `json:"notify,omitempty"`
|
||||
|
||||
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
|
||||
// DefaultProposalCooldown (24h).
|
||||
Cooldown Duration `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
// AnnounceProposals reports whether inferred routines may be announced. Safe
|
||||
// on a nil receiver — an absent config block means silent detection.
|
||||
func (p *PatternProposalConfig) AnnounceProposals() bool {
|
||||
return p != nil && p.Notify
|
||||
}
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
@@ -448,6 +488,11 @@ const (
|
||||
DefaultLLMRouter = true
|
||||
|
||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
||||
|
||||
// DefaultProposalCooldown — one inferred-routine announcement per day at
|
||||
// most. A proposal is never urgent; if two patterns surface in the same
|
||||
// hour, the second one waits, and the /routines page has it either way.
|
||||
DefaultProposalCooldown = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Load reads the JSON config at path and applies defaults. A missing file is
|
||||
@@ -520,6 +565,12 @@ func (c *Config) applyDefaults() {
|
||||
c.Digest.SeverityCeiling = 2
|
||||
}
|
||||
|
||||
// Absent block stays nil (⇒ silent detection). Present-but-partial gets the
|
||||
// cooldown default, so `{"notify": true}` is enough to switch it on.
|
||||
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
|
||||
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
|
||||
}
|
||||
|
||||
if c.Voice != nil {
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
|
||||
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
|
||||
const MaxIntervalRatio = 1.5
|
||||
|
||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
||||
// With N events, there are N-1 intervals; we need at least 2 intervals
|
||||
// before proposing anything.
|
||||
const MinEvents = 3
|
||||
// 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:
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestDetectEnoughEvents(t *testing.T) {
|
||||
// 3 events with 7-day intervals → stable pattern
|
||||
// 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)
|
||||
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
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 != 3 {
|
||||
t.Fatalf("want N=3, got %d", r.N)
|
||||
if r.N != 4 {
|
||||
t.Fatalf("want N=4, got %d", r.N)
|
||||
}
|
||||
// ~7 days
|
||||
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
||||
@@ -33,19 +34,28 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("want nil for <3 events")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +78,13 @@ func TestDetectEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectIrregularRejects(t *testing.T) {
|
||||
// 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
||||
// 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)
|
||||
@@ -117,6 +128,7 @@ func TestDetectSameTimestamp(t *testing.T) {
|
||||
{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)
|
||||
|
||||
Reference in New Issue
Block a user