// mavend/patterns.go — the shared detect+propose step of pattern inference // (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact- // write time in detectPattern below, 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" "log" "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 } // detectPattern extracts an event from the written fact and runs the pattern // detector. If a stable recurring pattern is found and no proposed routine // exists for this action+object yet, one is created and the user is prompted // to confirm via the park() mechanism. Returns the suggestion phrase when a // new proposal was created and parked; "" otherwise. func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string { ev := pattern.Extract(factID, key, value, ts) if ev == nil { return "" // not an actionable event } if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil { log.Printf("voice: create event: %v", err) return "" } // Detect+propose (Vikunja #43) is shared with the digestion tick's // proactive scan — see detectAndPropose above. Event *extraction* stays // here, tied to this fact write; detection over the accumulated history does // not need to happen right now for the voice path to have already done // its job — it's dedupe-safe to also let the next tick find the same // pattern independently. r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts) if err != nil { log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err) return "" } if r == nil { return "" // not enough data, too irregular, or already proposed/decided } log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) // Park the proposal for voice confirmation. phrase := pattern.PhraseRoutine(r) h.mu.Lock() h.pendingRoutine = &pendingRoutineConfirm{ routineID: id, action: r.Action, object: r.Object, interval: r.IntervalDays, phrase: phrase, expiry: ts.Add(confirmTTL), } h.mu.Unlock() return phrase }