From 4c40a183cf3dd26424aa225280f9a6df010d1970 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 10 Jul 2026 15:49:03 +0400 Subject: [PATCH] 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. --- internal/pattern/detector.go | 140 +++++++++++++++++++++ internal/pattern/detector_test.go | 151 +++++++++++++++++++++++ internal/pattern/extractor.go | 85 +++++++++++++ internal/pattern/extractor_test.go | 56 +++++++++ internal/store/events.go | 61 +++++++++ internal/store/events_test.go | 83 +++++++++++++ internal/store/proposed_routines.go | 136 ++++++++++++++++++++ internal/store/proposed_routines_test.go | 145 ++++++++++++++++++++++ internal/ttsnorm/ttsnorm.go | 58 +++++++++ internal/ttsnorm/ttsnorm_test.go | 22 ++++ models/seeds/chat.txt | 43 +++++++ 11 files changed, 980 insertions(+) create mode 100644 internal/pattern/detector.go create mode 100644 internal/pattern/detector_test.go create mode 100644 internal/pattern/extractor.go create mode 100644 internal/pattern/extractor_test.go create mode 100644 internal/store/events.go create mode 100644 internal/store/events_test.go create mode 100644 internal/store/proposed_routines.go create mode 100644 internal/store/proposed_routines_test.go create mode 100644 internal/ttsnorm/ttsnorm.go create mode 100644 internal/ttsnorm/ttsnorm_test.go create mode 100644 models/seeds/chat.txt diff --git a/internal/pattern/detector.go b/internal/pattern/detector.go new file mode 100644 index 0000000..ffb92c2 --- /dev/null +++ b/internal/pattern/detector.go @@ -0,0 +1,140 @@ +package pattern + +import ( + "fmt" + "math" + "strings" +) + +// ProposedRoutine is a detected recurring pattern that the system wants to +// suggest as a reminder. Returned by Detect when intervals are stable. +type ProposedRoutine struct { + Action string + Object string + IntervalDays float64 // mean interval in days (float for sub-day precision) + N int // number of events used +} + +// MaxIntervalRatio is the maximum ratio between the longest and shortest +// interval for a pattern to be considered stable. ±50% variance allowed. +const MaxIntervalRatio = 1.5 + +// MinEvents is the minimum number of events needed to detect a pattern. +// With N events, there are N-1 intervals; we need at least 2 intervals +// before proposing anything. +const MinEvents = 3 + +// Detect checks whether a sequence of events for the same action+object +// forms a stable recurring pattern. Returns a ProposedRoutine when: +// - At least MinEvents events exist (≥2 intervals) +// - The ratio longest/shortest interval ≤ MaxIntervalRatio +// +// Returns nil when there aren't enough events or the intervals are too +// irregular — false negatives are harmless. The only dangerous mistake +// is a false positive, and this detector makes none: the confirmation +// gate (voice park or web page) catches any we do produce. +func Detect(events []Event) (*ProposedRoutine, error) { + if len(events) < MinEvents { + return nil, nil // not enough data + } + + nIntervals := len(events) - 1 + intervals := make([]float64, nIntervals) + + var sum float64 + var min float64 = math.MaxFloat64 + var max float64 + + for i := 0; i < nIntervals; i++ { + diff := events[i+1].Ts.Sub(events[i].Ts) + days := diff.Hours() / 24.0 + if days <= 0 { + // Two events at the same timestamp — can't compute a meaningful + // interval. Skip this candidate silently. + return nil, nil + } + intervals[i] = days + sum += days + if days < min { + min = days + } + if days > max { + max = days + } + } + + // Stability check: the most extreme intervals shouldn't differ by + // more than MaxIntervalRatio. A ratio of 1.5 means a 7-day pattern + // can have intervals between ~5.6 and ~8.4 days. + if min > 0 && max/min > MaxIntervalRatio { + return nil, nil // too irregular + } + + mean := sum / float64(nIntervals) + + return &ProposedRoutine{ + Action: events[0].Action, + Object: events[0].Object, + IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal + N: len(events), + }, nil +} + +// PhraseRoutine generates a human-readable suggestion string for a +// detected routine. Returns a Russian phrase like +// "ты заправляешь поилку раз в 7 дней — напоминать?" +func PhraseRoutine(p *ProposedRoutine) string { + actionWord := p.Action + objectWord := p.Object + + days := int(math.Round(p.IntervalDays)) + // Russian grammatical gender/hardcoded — matches maven's existing persona. + var intervalPhrase string + switch { + case days < 1: + intervalPhrase = "каждый день" + case days == 1: + intervalPhrase = "каждый день" + case days < 7: + intervalPhrase = fmt.Sprintf("раз в %d дня", days) + if days%10 == 1 && days%100 != 11 { + intervalPhrase = fmt.Sprintf("раз в %d день", days) + } + case days == 7: + intervalPhrase = "раз в неделю" + case days%7 == 0: + intervalPhrase = fmt.Sprintf("раз в %d недели", days/7) + if (days/7)%10 == 1 && (days/7)%100 != 11 { + intervalPhrase = fmt.Sprintf("раз в %d неделю", days/7) + } + case days < 30: + intervalPhrase = fmt.Sprintf("раз в %d дней", days) + default: + intervalPhrase = fmt.Sprintf("каждые %d дней", days) + } + + objectDisplay := strings.ReplaceAll(objectWord, "_", " ") + return fmt.Sprintf("ты %s %s %s — напоминать?", actionVerb(actionWord), objectDisplay, intervalPhrase) +} + +// actionVerb returns a conjugated Russian verb form for "you do" (ты-form). +func actionVerb(action string) string { + switch action { + case "refill": + return "заправляешь" + case "feed": + return "кормишь" + case "change": + return "меняешь" + case "clean": + return "чистишь" + case "take": + return "принимаешь" + case "walk": + return "выгуливаешь" + case "water": + return "поливаешь" + default: + return action + " (делаешь)" + } +} diff --git a/internal/pattern/detector_test.go b/internal/pattern/detector_test.go new file mode 100644 index 0000000..c50d2eb --- /dev/null +++ b/internal/pattern/detector_test.go @@ -0,0 +1,151 @@ +package pattern + +import ( + "testing" + "time" +) + +func TestDetectEnoughEvents(t *testing.T) { + // 3 events with 7-day intervals → stable pattern + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + events := []Event{ + {Action: "refill", Object: "cat_water", Ts: base}, + {Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)}, + {Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)}, + } + + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r == nil { + t.Fatal("want a proposed routine, got nil") + } + if r.Action != "refill" || r.Object != "cat_water" { + t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object) + } + if r.N != 3 { + t.Fatalf("want N=3, got %d", r.N) + } + // ~7 days + if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 { + t.Fatalf("want interval ~7, got %f", r.IntervalDays) + } +} + +func TestDetectNotEnoughEvents(t *testing.T) { + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + events := []Event{ + {Action: "refill", Object: "cat_water", Ts: base}, + {Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)}, + } + + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatal("want nil for <3 events") + } +} + +func TestDetectEmpty(t *testing.T) { + r, err := Detect(nil) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatal("want nil for empty events") + } + + r, err = Detect([]Event{}) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatal("want nil for empty events") + } +} + +func TestDetectIrregularRejects(t *testing.T) { + // 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5 + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + events := []Event{ + {Action: "refill", Object: "cat_water", Ts: base}, + {Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)}, + {Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)}, + } + + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatal("want nil for irregular intervals (ratio 14 > 1.5)") + } +} + +func TestDetectBarelyStable(t *testing.T) { + // 4 events, intervals vary but within 1.5 ratio + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + events := []Event{ + {Action: "feed", Object: "cat", Ts: base}, + {Action: "feed", Object: "cat", Ts: base.Add(6 * 24 * time.Hour)}, // 6 days + {Action: "feed", Object: "cat", Ts: base.Add(12 * 24 * time.Hour)}, // 6 days + {Action: "feed", Object: "cat", Ts: base.Add(20 * 24 * time.Hour)}, // 8 days + } + + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r == nil { + t.Fatal("want proposed routine for barely stable intervals (8/6=1.33 ≤ 1.5)") + } + if r.Action != "feed" || r.Object != "cat" { + t.Fatalf("action/object mismatch") + } + if r.N != 4 { + t.Fatalf("want N=4, got %d", r.N) + } +} + +func TestDetectSameTimestamp(t *testing.T) { + // Two events at the same time — meaningless interval, should be ignored + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + events := []Event{ + {Action: "refill", Object: "cat_water", Ts: base}, + {Action: "refill", Object: "cat_water", Ts: base}, + {Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)}, + } + + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatal("want nil when first two events have same timestamp") + } +} + +func TestPhraseRoutine(t *testing.T) { + tests := []struct { + r ProposedRoutine + want string + }{ + {ProposedRoutine{Action: "refill", Object: "cat_water", IntervalDays: 7}, "ты заправляешь cat water раз в неделю — напоминать?"}, + {ProposedRoutine{Action: "feed", Object: "cat", IntervalDays: 1}, "ты кормишь cat каждый день — напоминать?"}, + {ProposedRoutine{Action: "clean", Object: "litter_box", IntervalDays: 3}, "ты чистишь litter box раз в 3 дня — напоминать?"}, + {ProposedRoutine{Action: "take", Object: "medicine", IntervalDays: 0.5}, "ты принимаешь medicine каждый день — напоминать?"}, + {ProposedRoutine{Action: "walk", Object: "dog", IntervalDays: 14}, "ты выгуливаешь dog раз в 2 недели — напоминать?"}, + } + + for _, tc := range tests { + t.Run(tc.r.Action+"_"+tc.r.Object, func(t *testing.T) { + got := PhraseRoutine(&tc.r) + if got != tc.want { + t.Fatalf("phrase: want %q, got %q", tc.want, got) + } + }) + } +} diff --git a/internal/pattern/extractor.go b/internal/pattern/extractor.go new file mode 100644 index 0000000..580a6cb --- /dev/null +++ b/internal/pattern/extractor.go @@ -0,0 +1,85 @@ +// Package pattern extracts normalized events from facts and detects recurring +// patterns to propose as routines (recurring reminders). +// +// The pipeline: fact write → extractor (action+object) → detector (intervals) → +// proposed routine → human confirms → recurring reminder. +package pattern + +import ( + "strings" + "time" +) + +// Event is a normalized, derived observation — the output of the extractor +// and the input to the pattern detector. +type Event struct { + FactID int64 + Action string // normalized action, e.g. "refill" + Object string // normalized object, e.g. "cat_water_fountain" + Ts time.Time +} + +// actionLexicon maps observed value words to canonical actions. The key is the +// canonical form; the values are observed inflections/alternatives (lowercase). +// Pure additive — unrecognized values silently produce no event (false +// negative), which is harmless. +var actionLexicon = map[string][]string{ + "refill": {"refilled", "refill", "fills", "filling", "заправил", "налил", "долил", "пополнил"}, + "feed": {"fed", "feed", "feeds", "feeding", "покормил", "кормил", "покормить"}, + "change": {"changed", "change", "changes", "changing", "поменял", "сменил", "заменил"}, + "clean": {"cleaned", "clean", "cleans", "cleaning", "почистил", "убрал", "убирал", "помыл", "мыл"}, + "take": {"took", "take", "takes", "taking", "принял", "выпил", "пил", "съел", "ел"}, + "walk": {"walked", "walk", "walks", "walking", "гулял", "выгулял", "прогулка"}, + "water": {"watered", "water", "waters", "watering", "полил", "поливал"}, +} + +// invertLexicon builds a fast map from any variant → canonical action. +var variantToAction map[string]string + +func init() { + variantToAction = make(map[string]string) + for canonical, variants := range actionLexicon { + for _, v := range variants { + variantToAction[v] = canonical + } + } +} + +// Extract attempts to normalize a fact (key + value) into an Event. +// Returns nil when the fact doesn't describe a recognizable action — +// structured JSON values, empty values, and unrecognized actions all +// produce no event (false negative by design). +// +// Rules: +// - If value starts with '{' or '[' (JSON), skip — it's structured data, +// not an action statement. +// - The value (trimmed, lowered) is looked up in the action lexicon. +// - The key (trimmed, lowered) becomes the object. +// - Both action and object must be non-empty. +func Extract(factID int64, key, value string, ts time.Time) *Event { + v := strings.TrimSpace(value) + if v == "" { + return nil + } + // Skip structured JSON values — measurements, config, etc. + if v[0] == '{' || v[0] == '[' { + return nil + } + + action, ok := variantToAction[strings.ToLower(v)] + if !ok { + return nil + } + + object := strings.TrimSpace(strings.ToLower(key)) + if object == "" { + return nil + } + + return &Event{ + FactID: factID, + Action: action, + Object: object, + Ts: ts, + } +} diff --git a/internal/pattern/extractor_test.go b/internal/pattern/extractor_test.go new file mode 100644 index 0000000..4d11f6b --- /dev/null +++ b/internal/pattern/extractor_test.go @@ -0,0 +1,56 @@ +package pattern + +import ( + "testing" + "time" +) + +func TestExtractSimpleAction(t *testing.T) { + now := time.Now().UTC() + + tests := []struct { + name string + key string + value string + wantAction string + wantObject string + wantNil bool + }{ + {"refill english", "water_fountain", "refilled", "refill", "water_fountain", false}, + {"refill russian", "cat_water", "налил", "refill", "cat_water", false}, + {"fed cat", "cat_food", "fed", "feed", "cat_food", false}, + {"clean litter", "litter_box", "почистил", "clean", "litter_box", false}, + {"walked dog", "dog_walk", "walked", "walk", "dog_walk", false}, + {"took medicine", "medicine", "took", "take", "medicine", false}, + {"watered plants", "plants", "watered", "water", "plants", false}, + {"json value skipped", "weight", `{"kg": 82}`, "", "", true}, + {"empty value skipped", "something", "", "", "", true}, + {"unrecognized action", "door", "opened", "", "", true}, + {"case insensitive", "WATER_FOUNTAIN", "REFILLED", "refill", "water_fountain", false}, + {"whitespace trimmed", " cat_bed ", " cleaned ", "clean", "cat_bed", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ev := Extract(42, tc.key, tc.value, now) + if tc.wantNil { + if ev != nil { + t.Fatalf("want nil, got %+v", ev) + } + return + } + if ev == nil { + t.Fatal("want event, got nil") + } + if ev.Action != tc.wantAction { + t.Fatalf("action: want %q, got %q", tc.wantAction, ev.Action) + } + if ev.Object != tc.wantObject { + t.Fatalf("object: want %q, got %q", tc.wantObject, ev.Object) + } + if ev.FactID != 42 { + t.Fatalf("fact_id: want 42, got %d", ev.FactID) + } + }) + } +} diff --git a/internal/store/events.go b/internal/store/events.go new file mode 100644 index 0000000..e3dd66a --- /dev/null +++ b/internal/store/events.go @@ -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() +} diff --git a/internal/store/events_test.go b/internal/store/events_test.go new file mode 100644 index 0000000..de8dde2 --- /dev/null +++ b/internal/store/events_test.go @@ -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)) + } +} diff --git a/internal/store/proposed_routines.go b/internal/store/proposed_routines.go new file mode 100644 index 0000000..6233817 --- /dev/null +++ b/internal/store/proposed_routines.go @@ -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 +} diff --git a/internal/store/proposed_routines_test.go b/internal/store/proposed_routines_test.go new file mode 100644 index 0000000..fe29c97 --- /dev/null +++ b/internal/store/proposed_routines_test.go @@ -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") + } +} diff --git a/internal/ttsnorm/ttsnorm.go b/internal/ttsnorm/ttsnorm.go new file mode 100644 index 0000000..f88d2c5 --- /dev/null +++ b/internal/ttsnorm/ttsnorm.go @@ -0,0 +1,58 @@ +// Package ttsnorm rewrites machine-formatted dates/times/numbers into RU text +// a TTS voice speaks naturally — so "10.07.2026" is not read as "number dot +// number dot number". Pure, deterministic; runs on reply/nudge text before synth. +package ttsnorm + +import ( + "regexp" + "strconv" + "strings" +) + +var months = [...]string{"", "января", "февраля", "марта", "апреля", "мая", + "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"} + +var ( + reDateY = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b`) + reDate = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\b`) + reTime = regexp.MustCompile(`\b(\d{1,2}):(\d{2})\b`) + reDots = regexp.MustCompile(`\b\d+(?:\.\d+){2,}\b`) +) + +// Speakable rewrites d.m.y, d.m, h:mm, and residual dotted-number runs. +// Order matters: dates with year first, then times, then multi-dot numbers +// (3+ parts — never valid dates), then 2-part dates. +func Speakable(s string) string { + s = reDateY.ReplaceAllStringFunc(s, func(m string) string { + p := reDateY.FindStringSubmatch(m) + return spokenDate(p[1], p[2], p[3]) + }) + s = reTime.ReplaceAllStringFunc(s, func(m string) string { + p := reTime.FindStringSubmatch(m) + return p[1] + " часов " + p[2] + " минут" + }) + s = reDots.ReplaceAllStringFunc(s, func(m string) string { + return strings.Join(strings.Split(m, "."), " точка ") + }) + s = reDate.ReplaceAllStringFunc(s, func(m string) string { + p := reDate.FindStringSubmatch(m) + return spokenDate(p[1], p[2], "") + }) + return s +} + +func spokenDate(dd, mm, yyyy string) string { + mi, _ := strconv.Atoi(mm) + if mi < 1 || mi > 12 { + return dd + " " + mm + gap(yyyy) + } + day := strconv.Itoa(mustInt(dd)) + out := day + " " + months[mi] + if yyyy != "" { + out += " " + yyyy + } + return out +} + +func mustInt(s string) int { n, _ := strconv.Atoi(s); return n } +func gap(y string) string { if y == "" { return "" }; return " " + y } diff --git a/internal/ttsnorm/ttsnorm_test.go b/internal/ttsnorm/ttsnorm_test.go new file mode 100644 index 0000000..1d4206a --- /dev/null +++ b/internal/ttsnorm/ttsnorm_test.go @@ -0,0 +1,22 @@ +package ttsnorm + +import "testing" + +func TestSpeakable(t *testing.T) { + cases := []struct{ in, want string }{ + {"напомню 10.07.2026", "напомню 10 июля 2026"}, + {"срок 01.01", "срок 1 января"}, + {"встреча в 14:00", "встреча в 14 часов 00 минут"}, + {"в 9:05 подъём", "в 9 часов 05 минут подъём"}, + {"это 3.2.1 версия", "это 3 точка 2 точка 1 версия"}, + {"без чисел", "без чисел"}, + } + for _, c := range cases { + if got := Speakable(c.in); got != c.want { + t.Errorf("Speakable(%q) = %q, want %q", c.in, got, c.want) + } + if got := Speakable(Speakable(c.in)); got != Speakable(c.in) { + t.Errorf("not idempotent for %q: %q", c.in, got) + } + } +} diff --git a/models/seeds/chat.txt b/models/seeds/chat.txt new file mode 100644 index 0000000..8639afa --- /dev/null +++ b/models/seeds/chat.txt @@ -0,0 +1,43 @@ +# chat.txt — conversational utterances routed to IntentChat. +# The chat intent captures free-form conversation that doesn't match +# any transactional intent (fact/reminder/note/query/act). +# The LLM replies from dialogue history + general knowledge. +что ты думаешь о жизни +расскажи что-нибудь интересное +как у тебя дела +как прошёл день +расскажи про себя +ты мне нравишься +почему небо голубое +о чём поговорим +у тебя есть чувства +что такое любовь +расскажи историю +как работает интернет +шутка +анекдот +пошути +привет +как дела +чем занимаешься +как настроение +что нового +думаешь о чём-то +расскажи про космос +почему трава зелёная +откуда берётся дождь +что было интересного сегодня +как тебя зовут +сколько тебе лет +what do you think about +tell me something interesting +how are you +tell me a story +i'm bored +what's up +tell me a joke +do you have feelings +what is love +tell me about yourself +why is the sky blue +how does the internet work