feat: event store, pattern inference, and routine proposals
- Add events table (migration #4): stores normalized (action, object, ts) triples extracted from facts, indexed for recurrence detection. - Add proposed_routines table: stores inferred recurring patterns with proposed/accepted/dismissed status and optional linked reminder. - Add pattern package: Extractor normalizes fact text into (action, object) pairs with TTS normalization; Detector groups events to find recurring patterns and proposes routines. - Add internal/ttsnorm: text normalization pipeline for Russian/English (lowercase, punctuation strip, number normalization, stopword removal). - Add chat seed file for LLM phraser.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateAndQueryEvents(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Write a fact first (events reference facts)
|
||||
id1, err := s.WriteFact(ctx, now, KindSelf, "cat_water", "refilled", "tap:voice", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
id2, err := s.WriteFact(ctx, now.Add(7*24*time.Hour), KindSelf, "cat_water", "refilled", "tap:voice", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
id3, err := s.WriteFact(ctx, now.Add(14*24*time.Hour), KindSelf, "cat_water", "refilled", "tap:voice", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
|
||||
// Create events from the facts
|
||||
eid1, err := s.CreateEvent(ctx, id1, "refill", "cat_water", now)
|
||||
if err != nil {
|
||||
t.Fatalf("create event: %v", err)
|
||||
}
|
||||
if eid1 == 0 {
|
||||
t.Fatal("expected non-zero event id")
|
||||
}
|
||||
|
||||
_, err = s.CreateEvent(ctx, id2, "refill", "cat_water", now.Add(7*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("create event: %v", err)
|
||||
}
|
||||
_, err = s.CreateEvent(ctx, id3, "refill", "cat_water", now.Add(14*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("create event: %v", err)
|
||||
}
|
||||
|
||||
// Query events
|
||||
events, err := s.EventsFor(ctx, "refill", "cat_water")
|
||||
if err != nil {
|
||||
t.Fatalf("EventsFor: %v", err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("want 3 events, got %d", len(events))
|
||||
}
|
||||
|
||||
// Must be ordered by ts ASC
|
||||
if events[0].Ts.After(events[1].Ts) || events[1].Ts.After(events[2].Ts) {
|
||||
t.Fatal("events not in ascending ts order")
|
||||
}
|
||||
|
||||
// No events for a different action+object
|
||||
events, err = s.EventsFor(ctx, "feed", "cat")
|
||||
if err != nil {
|
||||
t.Fatalf("EventsFor: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("want 0 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsForEmpty(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
events, err := s.EventsFor(ctx, "nonexistent", "nothing")
|
||||
if err != nil {
|
||||
t.Fatalf("EventsFor: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("want 0 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProposedRoutine — a detected pattern the system wants to turn into a
|
||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
||||
// set); 'dismissed' means the human declined and we won't re-propose.
|
||||
type ProposedRoutine struct {
|
||||
ID int64
|
||||
Action string
|
||||
Object string
|
||||
IntervalDays float64
|
||||
Status string // proposed | accepted | dismissed
|
||||
CreatedTs time.Time
|
||||
ReminderID *int64 // set when accepted
|
||||
}
|
||||
|
||||
var (
|
||||
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
||||
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
||||
)
|
||||
|
||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||
// ErrProposedRoutineExists if one already exists for this action+object (any
|
||||
// status) — the pattern detector should only propose once per pair.
|
||||
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||
VALUES (?,?,?,'proposed',?)
|
||||
ON CONFLICT(action, object) DO NOTHING`,
|
||||
action, object, intervalDays, ts.UnixMilli())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create proposed routine: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create proposed routine: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, ErrProposedRoutineExists
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create proposed routine: last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// LookupProposedRoutine returns the proposed routine for action+object, or
|
||||
// nil (no error) when no row exists.
|
||||
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||
FROM proposed_routines
|
||||
WHERE action = ? AND object = ?`, action, object)
|
||||
r, err := scanProposedRoutine(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListProposedRoutines returns all proposed routines with status='proposed',
|
||||
// newest first.
|
||||
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||
FROM proposed_routines
|
||||
WHERE status = 'proposed'
|
||||
ORDER BY created_ts DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list proposed routines: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProposedRoutine
|
||||
for rows.Next() {
|
||||
r, err := scanProposedRoutine(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
||||
// Returns error if not in 'proposed' status.
|
||||
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
||||
reminderID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("accept proposed routine: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: id=%d not in 'proposed' status", ErrProposedRoutineNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DismissProposedRoutine flips status to 'dismissed'. Idempotent.
|
||||
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE proposed_routines SET status = 'dismissed' WHERE id = ? AND status = 'proposed'`,
|
||||
id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dismiss proposed routine: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanProposedRoutine scans a row into ProposedRoutine.
|
||||
func scanProposedRoutine(sc scanner) (ProposedRoutine, error) {
|
||||
var r ProposedRoutine
|
||||
var created int64
|
||||
var reminderID sql.NullInt64
|
||||
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil {
|
||||
return ProposedRoutine{}, err
|
||||
}
|
||||
r.CreatedTs = time.UnixMilli(created).UTC()
|
||||
if reminderID.Valid {
|
||||
r.ReminderID = &reminderID.Int64
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateAndAcceptProposedRoutine(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create
|
||||
id, err := s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatal("expected non-zero id")
|
||||
}
|
||||
|
||||
// Duplicate should fail
|
||||
_, err = s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now)
|
||||
if !errors.Is(err, ErrProposedRoutineExists) {
|
||||
t.Fatalf("want ErrProposedRoutineExists, got %v", err)
|
||||
}
|
||||
|
||||
// Lookup
|
||||
r, err := s.LookupProposedRoutine(ctx, "refill", "cat_water")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupProposedRoutine: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("want non-nil routine")
|
||||
}
|
||||
if r.Action != "refill" || r.Object != "cat_water" || r.Status != "proposed" {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
if r.IntervalDays != 7.0 {
|
||||
t.Fatalf("want interval_days=7.0, got %f", r.IntervalDays)
|
||||
}
|
||||
|
||||
// Accept
|
||||
// First create a reminder to link
|
||||
remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil {
|
||||
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||
}
|
||||
|
||||
// Verify accepted
|
||||
r, err = s.LookupProposedRoutine(ctx, "refill", "cat_water")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupProposedRoutine: %v", err)
|
||||
}
|
||||
if r.Status != "accepted" {
|
||||
t.Fatalf("want status=accepted, got %s", r.Status)
|
||||
}
|
||||
if r.ReminderID == nil || *r.ReminderID != remID {
|
||||
t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissProposedRoutine(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
id, err := s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
||||
t.Fatalf("DismissProposedRoutine: %v", err)
|
||||
}
|
||||
|
||||
r, err := s.LookupProposedRoutine(ctx, "feed", "cat")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupProposedRoutine: %v", err)
|
||||
}
|
||||
if r.Status != "dismissed" {
|
||||
t.Fatalf("want status=dismissed, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProposedRoutines(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// No routines yet
|
||||
list, err := s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProposedRoutines: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("want 0, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create two
|
||||
_, err = s.CreateProposedRoutine(ctx, "refill", "water", 7.0, now)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||
}
|
||||
_, err = s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||
}
|
||||
|
||||
list, err = s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProposedRoutines: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("want 2, got %d", len(list))
|
||||
}
|
||||
|
||||
// Dismiss one
|
||||
_ = s.DismissProposedRoutine(ctx, list[0].ID)
|
||||
list, err = s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProposedRoutines: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want 1 proposed after dismissing one, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMissingProposedRoutine(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
r, err := s.LookupProposedRoutine(ctx, "nonexistent", "nothing")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupProposedRoutine: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("want nil for missing routine")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user