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:
kami
2026-07-10 15:49:03 +04:00
parent c0c11cd36d
commit 4c40a183cf
11 changed files with 980 additions and 0 deletions
+140
View File
@@ -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 + " (делаешь)"
}
}
+151
View File
@@ -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)
}
})
}
}
+85
View File
@@ -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,
}
}
+56
View File
@@ -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)
}
})
}
}
+61
View File
@@ -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()
}
+83
View File
@@ -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))
}
}
+136
View File
@@ -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
}
+145
View File
@@ -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")
}
}
+58
View File
@@ -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 }
+22
View File
@@ -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)
}
}
}
+43
View File
@@ -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