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)
}
})
}
}