// 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 }