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,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 + " (делаешь)"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user