Files
Maven/internal/store/events.go
T
kami 67563ed1f6 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
2026-07-31 23:07:33 +04:00

92 lines
2.9 KiB
Go

package store
import (
"context"
"fmt"
"time"
)
// Event — a normalized, derived observation from a fact. An event has an action
// (what happened, e.g. "refill") and an object (what it happened to, e.g.
// "cat_water_fountain"). Events are lossy — not every fact produces one.
// They are the input to the pattern detector.
type Event struct {
ID int64
FactID int64
Action string
Object string
Ts time.Time
}
// CreateEvent persists a derived event from a fact. Best-effort: the caller
// (the pattern extractor) decides whether the fact warrants an event; this
// just writes the row. Not every fact produces an event.
func (s *Store) CreateEvent(ctx context.Context, factID int64, action, object string, ts time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO events (fact_id, action, object, ts) VALUES (?,?,?,?)`,
factID, action, object, ts.UnixMilli())
if err != nil {
return 0, fmt.Errorf("create event: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create event: last insert id: %w", err)
}
return id, nil
}
// EventPair identifies one action+object grouping in the events table — the
// unit the pattern detector reasons about.
type EventPair struct {
Action string
Object string
}
// DistinctEventPairs returns every distinct action+object pair that has at
// least one event, in no particular order. This is what lets the proactive
// digestion tick run the pattern detector over everything accumulated so far
// instead of only the pair touched by the utterance that just landed
// (Vikunja #43) — the tick has no "current utterance," so it has to ask the
// store what to look at.
func (s *Store) DistinctEventPairs(ctx context.Context) ([]EventPair, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT action, object FROM events`)
if err != nil {
return nil, fmt.Errorf("distinct event pairs: %w", err)
}
defer rows.Close()
var out []EventPair
for rows.Next() {
var p EventPair
if err := rows.Scan(&p.Action, &p.Object); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// EventsFor returns all events matching action+object, ordered by ts ascending
// (oldest first — the order the pattern detector needs for interval computation).
func (s *Store) EventsFor(ctx context.Context, action, object string) ([]Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, fact_id, action, object, ts
FROM events
WHERE action = ? AND object = ?
ORDER BY ts ASC, id ASC`, action, object)
if err != nil {
return nil, fmt.Errorf("events for %s/%s: %w", action, object, err)
}
defer rows.Close()
var out []Event
for rows.Next() {
var e Event
var tsMillis int64
if err := rows.Scan(&e.ID, &e.FactID, &e.Action, &e.Object, &tsMillis); err != nil {
return nil, err
}
e.Ts = time.UnixMilli(tsMillis).UTC()
out = append(out, e)
}
return out, rows.Err()
}