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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user