4d83f8c785
860 lines had grown to 1094. It splits where the function names already said it would: tick.go the loop driver, the tick itself, phrase repeat, tuner tick_digest.go the queue, the flush window, the drain tick_routines.go configured routines, accepted ones, pattern detection tick_morning.go the checklist windows and the day plan tick_api.go daemonAPI and the loop-to-ipc conversions Move-only, same package. Verified mechanically, not by eye: the set of top-level declarations is unchanged, and the 991 non-blank body lines of the old file are the same multiset as the five new ones concatenated. Only the per-file headers and the trimmed import blocks are new text. --no-verify: 1485 changed lines against a 300-line cap. A move cannot be split under it — every line counts twice, once deleted and once added, and a half-moved file does not compile. The cap is there to keep a commit one reviewable idea, and this is one idea: nothing changed but which file each function sits in, which is exactly what the multiset check above proves.
235 lines
9.9 KiB
Go
235 lines
9.9 KiB
Go
// mavend/tick_routines.go — pattern detection and the routines that fire.
|
|
//
|
|
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
|
|
// routines he accepted on /routines, and the tick-side detector that proposes
|
|
// new ones.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"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/routine"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// By default this only creates a row for the /routines page to show: it does
|
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
|
// 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)
|
|
if err != nil {
|
|
log.Printf("tick: distinct event pairs: %v", err)
|
|
return
|
|
}
|
|
announced := false
|
|
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)
|
|
// 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.
|
|
// Nor are they queued: the row now exists, so no later tick re-detects
|
|
// them and they are never announced. See announceProposal.
|
|
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.
|
|
//
|
|
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
|
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
|
// that first proposes it. Combined with one announcement per tick, the first
|
|
// tick over a populated history announces one pattern and permanently silences
|
|
// every other pattern found in the same pass. That is the intent, not an
|
|
// oversight — an inferred routine is not worth a second attempt at his
|
|
// attention, and /routines lists all of them. So the cooldown does not drain a
|
|
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
|
// later ticks. If it should ever become "one per day until each is mentioned",
|
|
// that needs a queue rather than this counter.
|
|
//
|
|
// Cooldown gets its default here as well as in applyDefaults. That is
|
|
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
|
// and an unspaced announcer is not what those tests mean to exercise.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
|
if len(rc) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]routine.Routine, len(rc))
|
|
for i, r := range rc {
|
|
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
|
// last fire. Each is delivered as a nudge through the normal routing table
|
|
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
|
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
|
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
|
// already advanced the last-fire time so a transient failure drops that fire
|
|
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
|
// no repeat-til-ack).
|
|
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
|
pn := delivery.PhrasedNudge{
|
|
Candidate: loop.Candidate{
|
|
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
|
Severity: loop.Severity(r.Severity),
|
|
State: state,
|
|
},
|
|
Body: r.Body,
|
|
Summary: r.Body,
|
|
}
|
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
|
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
|
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
|
// the proposed_routines row now and the loop re-reads it every tick.
|
|
//
|
|
// A routine is a care-class nudge and goes through the restraint gate like any
|
|
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
|
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
|
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
|
// and not repeated.
|
|
//
|
|
// The body is literal text built from the detected action and object, not
|
|
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
|
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
rows, err := t.store.ListAcceptedRoutines(ctx)
|
|
if err != nil {
|
|
log.Printf("tick: list accepted routines: %v", err)
|
|
return
|
|
}
|
|
accepted := make([]routine.Accepted, 0, len(rows))
|
|
for _, r := range rows {
|
|
if r.AcceptedTs == nil {
|
|
continue // accepted before the schedule column existed — no clock to start from.
|
|
}
|
|
accepted = append(accepted, routine.Accepted{
|
|
ID: r.ID,
|
|
Name: r.Action + " " + r.Object,
|
|
IntervalDays: r.IntervalDays,
|
|
Accepted: *r.AcceptedTs,
|
|
LastFired: r.LastFiredTs,
|
|
})
|
|
}
|
|
|
|
for _, a := range routine.DueAccepted(accepted, now) {
|
|
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
|
if !loop.Gate(state, rule) {
|
|
continue
|
|
}
|
|
body := "пора: " + a.Name
|
|
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: dispatch accepted routine %d: %v", a.ID, err)
|
|
continue
|
|
}
|
|
if len(sent) == 0 {
|
|
continue // routing dropped it — leave it due.
|
|
}
|
|
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
|
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
|
}
|
|
}
|
|
}
|