Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766ca091a7 |
+2
-2
@@ -261,7 +261,7 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
@@ -438,7 +438,7 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
|
||||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||||
|
|||||||
+174
-11
@@ -3,9 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,16 +32,16 @@ func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base t
|
|||||||
|
|
||||||
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
||||||
// its own, reading straight from the store — not as a side effect of a live
|
// its own, reading straight from the store — not as a side effect of a live
|
||||||
// utterance (Vikunja #43). Three weekly events with no voice turn in sight
|
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
|
||||||
// must produce exactly one proposed routine.
|
// sight must produce exactly one proposed routine.
|
||||||
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
tl.detectPatterns(ctx, now.Add(time.Hour))
|
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,8 +104,8 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
|
|
||||||
// More events for the same pair arrive, and the tick runs again — a
|
// More events for the same pair arrive, and the tick runs again — a
|
||||||
// dismissed pattern must not resurface.
|
// dismissed pattern must not resurface.
|
||||||
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
|
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
|
||||||
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
|
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
|
||||||
|
|
||||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
t.Errorf("status = %s, want dismissed", all[0].Status)
|
t.Errorf("status = %s, want dismissed", all[0].Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proposalRule — the rule name announceProposal uses for the seeded pair.
|
||||||
|
const proposalRule = "proposal:refill cat_water"
|
||||||
|
|
||||||
|
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
|
||||||
|
// With no pattern_proposals block the tick still records the proposal, and says
|
||||||
|
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
|
||||||
|
// being asked stays off until it is configured.
|
||||||
|
func TestTickProposalSilentByDefault(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("announced %d proposals with no config, want 0", n)
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
|
||||||
|
// out once through the ordinary delivery path, worded by the detector itself.
|
||||||
|
// Later ticks stay quiet because the pair is already proposed: one pattern is
|
||||||
|
// one announcement, ever.
|
||||||
|
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
var got *delivery.Sendable
|
||||||
|
for i := range sink.sends {
|
||||||
|
if sink.sends[i].RuleName == proposalRule {
|
||||||
|
got = &sink.sends[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Body, "напоминать?") {
|
||||||
|
t.Errorf("body = %q, want the detector's own question", got.Body)
|
||||||
|
}
|
||||||
|
if got.Channel != delivery.ChannelVoice {
|
||||||
|
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A month of further ticks: the pair already has a row, so there is
|
||||||
|
// nothing new to detect and nothing more to say.
|
||||||
|
sink.sends = nil
|
||||||
|
later := now.Add(40 * 24 * time.Hour)
|
||||||
|
markPresent(t, st, ctx, later)
|
||||||
|
tl.tick(ctx, later)
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
|
||||||
|
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
|
||||||
|
// it is not announced at all: it is not held, not retried, it just lives on
|
||||||
|
// /routines. The proposal row is still written — noticing is never gated.
|
||||||
|
func TestTickProposalRespectsGate(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
// no presence probes ⇒ away ⇒ care-class gate blocks.
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("away: announced %d proposals, want 0", n)
|
||||||
|
}
|
||||||
|
if !tl.lastProposalAt.IsZero() {
|
||||||
|
t.Error("cooldown clock advanced on a suppressed announcement")
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
|
||||||
|
// same tick must not become two interruptions. The second one waits for the
|
||||||
|
// cooldown, and is on /routines meanwhile.
|
||||||
|
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
for i := 0; i < pattern.MinEvents; i++ {
|
||||||
|
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
|
||||||
|
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write fact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
|
||||||
|
t.Fatalf("create event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
announced := 0
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||||
|
announced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if announced != 1 {
|
||||||
|
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still inside the cooldown: silence, even though a proposal is pending.
|
||||||
|
sink.sends = nil
|
||||||
|
soon := now.Add(time.Hour)
|
||||||
|
markPresent(t, st, ctx, soon)
|
||||||
|
tl.tick(ctx, soon)
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||||
|
t.Fatalf("announced %q inside the cooldown", s.RuleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+76
-6
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
|||||||
morningRoutines []morning.Routine
|
morningRoutines []morning.Routine
|
||||||
morningLast map[string]time.Time
|
morningLast map[string]time.Time
|
||||||
|
|
||||||
|
// proposalCfg — announcement policy for routines the tick inferred itself.
|
||||||
|
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
|
||||||
|
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
|
||||||
|
// one more announcement, and a restart-per-day loop is a bigger problem
|
||||||
|
// than a duplicate proposal notice.
|
||||||
|
proposalCfg *config.PatternProposalConfig
|
||||||
|
lastProposalAt time.Time
|
||||||
|
|
||||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||||
digestQ []QueuedNudge
|
digestQ []QueuedNudge
|
||||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
|||||||
digestCfg *config.DigestConfig,
|
digestCfg *config.DigestConfig,
|
||||||
routines []routine.Routine,
|
routines []routine.Routine,
|
||||||
morningRoutines []morning.Routine,
|
morningRoutines []morning.Routine,
|
||||||
|
proposalCfg *config.PatternProposalConfig,
|
||||||
) *tickLoop {
|
) *tickLoop {
|
||||||
return &tickLoop{
|
return &tickLoop{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
|||||||
routineLast: make(map[string]time.Time),
|
routineLast: make(map[string]time.Time),
|
||||||
morningRoutines: morningRoutines,
|
morningRoutines: morningRoutines,
|
||||||
morningLast: make(map[string]time.Time),
|
morningLast: make(map[string]time.Time),
|
||||||
|
proposalCfg: proposalCfg,
|
||||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +223,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// path, so a pattern already sitting in history went unnoticed until he
|
// path, so a pattern already sitting in history went unnoticed until he
|
||||||
// happened to mention it again by voice. See patterns.go and
|
// happened to mention it again by voice. See patterns.go and
|
||||||
// detectPatterns below for how idempotence and dismissal are respected.
|
// detectPatterns below for how idempotence and dismissal are respected.
|
||||||
t.detectPatterns(ctx, now)
|
t.detectPatterns(ctx, now, state)
|
||||||
|
|
||||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||||
// delivery. a failed send leaves the reminder pending — the next tick
|
// delivery. a failed send leaves the reminder pending — the next tick
|
||||||
@@ -381,16 +392,19 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
|||||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
// calling the same shared path the voice route already used.
|
// calling the same shared path the voice route already used.
|
||||||
//
|
//
|
||||||
// This only ever creates a row for the /routines page to show. It does not
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
// and detection is not the same act as disturbing him about it. A proposal
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
// for the restraints that apply even then. A proposal only starts producing
|
||||||
|
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||||
|
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
announced := false
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -401,9 +415,65 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
|||||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||||
}
|
}
|
||||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
// One announcement per tick at most, whatever the scan turned up. The
|
||||||
|
// rest are on /routines; they are not lost, they are just not shouted.
|
||||||
|
if announced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced = t.announceProposal(ctx, r, now, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// announceProposal offers a freshly inferred routine through the ordinary
|
||||||
|
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||||
|
// something was actually sent.
|
||||||
|
//
|
||||||
|
// Everything here is restraint. The feature is off unless configured; when on
|
||||||
|
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||||
|
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||||
|
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||||
|
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||||
|
// real send, but the proposal row already exists, so the next tick will not
|
||||||
|
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||||
|
// he reads it on /routines instead, which is the whole point of the page.
|
||||||
|
//
|
||||||
|
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||||
|
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||||
|
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||||
|
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||||
|
if !t.proposalCfg.AnnounceProposals() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = config.DefaultProposalCooldown
|
||||||
|
}
|
||||||
|
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := pattern.PhraseRoutine(r)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
return false // routing dropped it — /routines still has it.
|
||||||
|
}
|
||||||
|
t.lastProposalAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
|||||||
Nudges: st,
|
Nudges: st,
|
||||||
Reminders: st,
|
Reminders: st,
|
||||||
})
|
})
|
||||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
|
||||||
|
|
||||||
// first tick: seeds, does not fire the routine.
|
// first tick: seeds, does not fire the routine.
|
||||||
tl.tick(ctx, now)
|
tl.tick(ctx, now)
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
"severity_ceiling": 2
|
"severity_ceiling": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"pattern_proposals": {
|
||||||
|
"notify": false,
|
||||||
|
"cooldown": "24h"
|
||||||
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
"praxis": { "url": "http://praxis:8989" },
|
"praxis": { "url": "http://praxis:8989" },
|
||||||
"hexis": { "url": "http://hexis:9741" },
|
"hexis": { "url": "http://hexis:9741" },
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ type Config struct {
|
|||||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||||
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
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
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// 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
|
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
|
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||||
@@ -448,6 +488,11 @@ const (
|
|||||||
DefaultLLMRouter = true
|
DefaultLLMRouter = true
|
||||||
|
|
||||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
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
|
// 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
|
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 != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
|
|||||||
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
|
|||||||
const MaxIntervalRatio = 1.5
|
const MaxIntervalRatio = 1.5
|
||||||
|
|
||||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
// 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
|
// With N events there are N-1 intervals, so 4 events means 3 intervals.
|
||||||
// before proposing anything.
|
//
|
||||||
const MinEvents = 3
|
// 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
|
// Detect checks whether a sequence of events for the same action+object
|
||||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDetectEnoughEvents(t *testing.T) {
|
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)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
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(7 * 24 * time.Hour)},
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 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)
|
r, err := Detect(events)
|
||||||
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
|
|||||||
if r.Action != "refill" || r.Object != "cat_water" {
|
if r.Action != "refill" || r.Object != "cat_water" {
|
||||||
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
||||||
}
|
}
|
||||||
if r.N != 3 {
|
if r.N != 4 {
|
||||||
t.Fatalf("want N=3, got %d", r.N)
|
t.Fatalf("want N=4, got %d", r.N)
|
||||||
}
|
}
|
||||||
// ~7 days
|
// ~7 days
|
||||||
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
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) {
|
func TestDetectNotEnoughEvents(t *testing.T) {
|
||||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
for _, n := range []int{1, 2, MinEvents - 1} {
|
||||||
{Action: "refill", Object: "cat_water", Ts: base},
|
events := make([]Event, n)
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
for i := range events {
|
||||||
}
|
events[i] = Event{
|
||||||
|
Action: "refill",
|
||||||
r, err := Detect(events)
|
Object: "cat_water",
|
||||||
if err != nil {
|
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
||||||
t.Fatalf("Detect: %v", err)
|
}
|
||||||
}
|
}
|
||||||
if r != nil {
|
r, err := Detect(events)
|
||||||
t.Fatal("want nil for <3 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) {
|
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)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
events := []Event{
|
||||||
{Action: "refill", Object: "cat_water", Ts: base},
|
{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(1 * 24 * time.Hour)},
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 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)
|
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},
|
{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(7 * 24 * time.Hour)},
|
||||||
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := Detect(events)
|
r, err := Detect(events)
|
||||||
|
|||||||
Reference in New Issue
Block a user