4c40a183cf
- Add events table (migration #4): stores normalized (action, object, ts) triples extracted from facts, indexed for recurrence detection. - Add proposed_routines table: stores inferred recurring patterns with proposed/accepted/dismissed status and optional linked reminder. - Add pattern package: Extractor normalizes fact text into (action, object) pairs with TTS normalization; Detector groups events to find recurring patterns and proposes routines. - Add internal/ttsnorm: text normalization pipeline for Russian/English (lowercase, punctuation strip, number normalization, stopword removal). - Add chat seed file for LLM phraser.
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
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 + " (делаешь)"
|
|
}
|
|
}
|