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
92 lines
2.9 KiB
Go
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()
|
|
}
|