Run pattern detection from the digestion tick, not just voice (#43)
detectPattern only ever fired as a side effect of a voice fact-write, so a recurring pattern already sitting in history went unnoticed until he happened to mention it again by voice — the opposite of proactive. Split the pipeline: extraction (fact -> normalized event) stays where a fact is written, in voice.go, since it's tied to that write regardless of who's talking. Detection (events -> stable pattern -> proposed_routines row) moves into shared code (patterns.go's detectAndPropose) that both the voice path and the new tick.go:detectPatterns call. The tick runs it every cycle over every action+object pair on record (store.DistinctEventPairs, added), so a pattern gets noticed on the daemon's own schedule. Idempotence and the dismiss-must-stick requirement turned out to already be handled by the store, not something the tick needs to reinvent: proposed_routines has UNIQUE(action, object) and CreateProposedRoutine does ON CONFLICT DO NOTHING, and DismissProposedRoutine flips status in place without deleting the row. So a pair already proposed, accepted, OR dismissed is a silent no-op on every later tick — a dismissed pattern can never resurface, and re-running the scan never spams the /routines page. Kept the voice-path call (immediate spoken confirmation is a nice feature UX-wise and is now redundant-but-harmless with the tick, since both paths share the same guarded detectAndPropose). Tick-side detection only ever writes a row; it does not notify, ring, or speak, keeping Maven "not a nag, not autonomous" — the /routines page is still the only place a proposal becomes visible, and only accepting it starts producing nudges (fireAcceptedRoutines). Also fixed the stale vikunja#46 reference in proposed_routines.go — the TODO it named is what this commit does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
@@ -195,6 +195,14 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||
t.fireMorningRoutines(ctx, now, state)
|
||||
|
||||
// pattern detection: scan every action+object pair with recorded events
|
||||
// and propose a routine for any stable one not already decided (Vikunja
|
||||
// #43). This used to only run as a side effect of the voice fact-write
|
||||
// path, so a pattern already sitting in history went unnoticed until he
|
||||
// happened to mention it again by voice. See patterns.go and
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
@@ -342,6 +350,49 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// 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
|
||||
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
|
||||
// and detection is not the same act as disturbing him about it. A proposal
|
||||
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// in config.Load, so this is a pure field copy.
|
||||
|
||||
Reference in New Issue
Block a user