67563ed1f6
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
75 lines
3.1 KiB
Go
75 lines
3.1 KiB
Go
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
|
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
|
// write time in voice.go's detectPattern, tied to whichever channel wrote the
|
|
// fact. Detection — turning a run of events into a proposed routine — is
|
|
// channel-agnostic: it only needs what's already in the events table, so it
|
|
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
|
// confirmation) and, proactively, from the digestion tick (tick.go's
|
|
// detectPatterns) over every action+object pair on record, not just the one
|
|
// that was just talked about.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/pattern"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// detectAndPropose runs the pattern detector over every recorded event for
|
|
// action+object and, if a stable pattern is found and nothing has been
|
|
// proposed/accepted/dismissed for this pair yet, creates a proposed_routines
|
|
// row. Returns (nil, 0, nil) — not an error — whenever there is nothing new
|
|
// to report: too few events, irregular intervals, or a pair that already has
|
|
// a row in any status. That last case is the one that matters most: it is
|
|
// how a routine the owner already DISMISSED stays dismissed forever, because
|
|
// the row survives dismissal (status flips in place, see
|
|
// store.DismissProposedRoutine) and both the Lookup check here and the
|
|
// table's UNIQUE(action, object) constraint refuse to create a second one.
|
|
func detectAndPropose(ctx context.Context, ds *store.Store, action, object string, ts time.Time) (*pattern.ProposedRoutine, int64, error) {
|
|
events, err := ds.EventsFor(ctx, action, object)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("events for %s/%s: %w", action, object, err)
|
|
}
|
|
patEvents := make([]pattern.Event, len(events))
|
|
for i, e := range events {
|
|
patEvents[i] = pattern.Event{
|
|
FactID: e.FactID,
|
|
Action: e.Action,
|
|
Object: e.Object,
|
|
Ts: e.Ts,
|
|
}
|
|
}
|
|
r, err := pattern.Detect(patEvents)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("detect %s/%s: %w", action, object, err)
|
|
}
|
|
if r == nil {
|
|
return nil, 0, nil // not enough data or intervals too irregular
|
|
}
|
|
|
|
// Belt: check first so the common "nothing new" case never even attempts
|
|
// an insert. Suspenders: CreateProposedRoutine's ON CONFLICT DO NOTHING
|
|
// (backed by the UNIQUE(action,object) constraint) is the actual
|
|
// guarantee — this Lookup is an optimization, not the source of truth.
|
|
existing, err := ds.LookupProposedRoutine(ctx, r.Action, r.Object)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("lookup proposed routine %s/%s: %w", action, object, err)
|
|
}
|
|
if existing != nil {
|
|
return nil, 0, nil // already proposed, accepted, or dismissed — say nothing
|
|
}
|
|
|
|
id, err := ds.CreateProposedRoutine(ctx, r.Action, r.Object, r.IntervalDays, ts)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrProposedRoutineExists) {
|
|
return nil, 0, nil // lost a race with another caller — not an error
|
|
}
|
|
return nil, 0, fmt.Errorf("create proposed routine %s/%s: %w", action, object, err)
|
|
}
|
|
return r, id, nil
|
|
}
|