4c40a183cf
- 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.
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
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
|
|
}
|