42d7a39c49
The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.
Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".
The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
450 lines
15 KiB
Go
450 lines
15 KiB
Go
package memory
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/say"
|
|
)
|
|
|
|
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
|
//
|
|
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
|
|
// asks for an LLM to write a behaviour profile daily and store it as a fact;
|
|
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
|
|
// will produce fluent claims about the owner's life that no row in the store
|
|
// supports, and a wrong claim about him is the most expensive kind of wrong
|
|
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
|
|
// to run on the question, and cannot invent a habit he does not have.
|
|
//
|
|
// Recomputed on read rather than cached as a fact for the same reason the store
|
|
// is append-only: a cached profile can disagree with the rows it came from, and
|
|
// then there are two truths. The plan's step 5 ("profile updates on fact write")
|
|
// exists to keep a cache fresh; there is no cache, so a new fact is already in
|
|
// the next answer.
|
|
//
|
|
// It is also read-only and unprompted-free. The plan's step 4 — a morning
|
|
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
|
|
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
|
|
// inference that leads to a routine the owner accepts already exists in
|
|
// internal/pattern with the proposal queue on /routines; that is the sanctioned
|
|
// path from "she noticed" to "she acts", and it goes through him.
|
|
|
|
// Observation — one thing the owner was recorded doing, reduced to what a habit
|
|
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
|
|
// so this package stays free of both.
|
|
type Observation struct {
|
|
At time.Time
|
|
Key string
|
|
Kind string // "self" | "env" | "config"
|
|
}
|
|
|
|
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
|
|
// days it was observed on, which is the number that decides whether something
|
|
// is a habit; Count can be inflated by one busy day.
|
|
//
|
|
// TypicalAt is the median time of day it happens at, rounded to the minute — a
|
|
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
|
|
// воду утром" into the night. It is a CIRCULAR median: the clock wraps, and a
|
|
// plain median of minutes-since-midnight reports 12:00 for a man who goes to
|
|
// bed at 23:50.
|
|
//
|
|
// HasTypical is false when the times are spread too widely for any of them to
|
|
// be typical (see maxTypicalSpread). She then names the habit without a time
|
|
// instead of naming a time she cannot support.
|
|
type Activity struct {
|
|
Key string
|
|
Days int
|
|
Count int
|
|
TypicalAt time.Duration
|
|
HasTypical bool
|
|
}
|
|
|
|
// Profile — the counted behaviour model.
|
|
//
|
|
// Weekly holds only the activities that DISTINGUISH a weekday: things he does
|
|
// on Tuesdays and not on most other days. Everyday holds the ones that recur
|
|
// across the week, and All holds both. The split exists because the two answer
|
|
// different questions, and conflating them produced the failure that named
|
|
// this: asked what he does on Saturdays, maven replied "ты пьёшь воду".
|
|
type Profile struct {
|
|
Since time.Time
|
|
Until time.Time
|
|
Weekly map[time.Weekday][]Activity
|
|
Everyday []Activity
|
|
All []Activity
|
|
}
|
|
|
|
// EverydaySpan — the number of weekdays an activity must be a habit on before
|
|
// it stops counting as characteristic of any one of them. Six of seven, not
|
|
// five: a weekday-only rhythm spans exactly five, and "по будням ты
|
|
// тренируешься" is a real answer about Tuesday. Six days a week is not.
|
|
const EverydaySpan = 6
|
|
|
|
// MinHabitDays — how many distinct days an activity must appear on before maven
|
|
// will call it usual. Two is the smallest number that can distinguish a habit
|
|
// from a one-off; below that she says she does not know yet, which is true.
|
|
const MinHabitDays = 2
|
|
|
|
// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
|
|
// behaviour. Calendar events carry the day in the key so they can never repeat;
|
|
// cooldown and quiet rows are maven's own tuning state, not his habits.
|
|
// quiet is listed with its separators rather than bare: as a five-letter
|
|
// prefix it would also swallow any future self-fact key that merely starts
|
|
// with those letters.
|
|
var nonBehaviouralKeyPrefixes = []string{
|
|
"calendar_event_",
|
|
"cooldown:",
|
|
"quiet_",
|
|
"quiet:",
|
|
"behavior_profile",
|
|
}
|
|
|
|
// nonBehaviouralKeys — exact keys, for the ones with no separator to anchor on.
|
|
var nonBehaviouralKeys = map[string]bool{"quiet": true}
|
|
|
|
// BuildProfile counts habits out of observations. now bounds the window's upper
|
|
// end and supplies the location every day boundary is taken in — a habit is
|
|
// "on Tuesdays" in the owner's timezone or it is nothing.
|
|
//
|
|
// Only self-facts count. An env row is the world (weather, a relayed meeting),
|
|
// and a config row is maven's own state; neither says anything about what he
|
|
// usually does.
|
|
func BuildProfile(obs []Observation, now time.Time) Profile {
|
|
loc := now.Location()
|
|
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
|
|
|
|
type bucket struct {
|
|
days map[string]struct{}
|
|
count int
|
|
mins []int
|
|
}
|
|
// key → bucket, and (weekday, key) → bucket.
|
|
all := map[string]*bucket{}
|
|
weekly := map[time.Weekday]map[string]*bucket{}
|
|
|
|
for _, o := range obs {
|
|
if o.Kind != "self" {
|
|
continue
|
|
}
|
|
key := canonicalize(o.Key)
|
|
if key == "" || nonBehavioural(key) {
|
|
continue
|
|
}
|
|
at := o.At.In(loc)
|
|
if at.IsZero() || at.After(now) {
|
|
continue
|
|
}
|
|
if p.Since.IsZero() || at.Before(p.Since) {
|
|
p.Since = at
|
|
}
|
|
day := at.Format("2006-01-02")
|
|
minute := at.Hour()*60 + at.Minute()
|
|
|
|
bump := func(m map[string]*bucket) {
|
|
b := m[key]
|
|
if b == nil {
|
|
b = &bucket{days: map[string]struct{}{}}
|
|
m[key] = b
|
|
}
|
|
b.days[day] = struct{}{}
|
|
b.count++
|
|
b.mins = append(b.mins, minute)
|
|
}
|
|
bump(all)
|
|
wd := at.Weekday()
|
|
if weekly[wd] == nil {
|
|
weekly[wd] = map[string]*bucket{}
|
|
}
|
|
bump(weekly[wd])
|
|
}
|
|
|
|
harvest := func(m map[string]*bucket) []Activity {
|
|
var out []Activity
|
|
for key, b := range m {
|
|
if len(b.days) < MinHabitDays {
|
|
continue
|
|
}
|
|
mid, known := circularMedianMinutes(b.mins)
|
|
out = append(out, Activity{
|
|
Key: key,
|
|
Days: len(b.days),
|
|
Count: b.count,
|
|
TypicalAt: time.Duration(mid) * time.Minute,
|
|
HasTypical: known,
|
|
})
|
|
}
|
|
// Most-established first, then earliest in the day, then by key so the
|
|
// same history always reads back the same way.
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Days != out[j].Days {
|
|
return out[i].Days > out[j].Days
|
|
}
|
|
if out[i].TypicalAt != out[j].TypicalAt {
|
|
return out[i].TypicalAt < out[j].TypicalAt
|
|
}
|
|
return out[i].Key < out[j].Key
|
|
})
|
|
return out
|
|
}
|
|
|
|
p.All = harvest(all)
|
|
|
|
// How many weekdays each key is a habit on. An activity that recurs on
|
|
// most days of the week is a daily habit, and naming it as an answer to
|
|
// "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is
|
|
// true of Saturday and of every other day, so it says nothing about
|
|
// Saturday. Those are held in Everyday and read back separately.
|
|
span := map[string]int{}
|
|
harvested := map[time.Weekday][]Activity{}
|
|
for wd, m := range weekly {
|
|
acts := harvest(m)
|
|
harvested[wd] = acts
|
|
for _, a := range acts {
|
|
span[a.Key]++
|
|
}
|
|
}
|
|
for wd, acts := range harvested {
|
|
var distinct []Activity
|
|
for _, a := range acts {
|
|
if span[a.Key] >= EverydaySpan {
|
|
continue
|
|
}
|
|
distinct = append(distinct, a)
|
|
}
|
|
if len(distinct) > 0 {
|
|
p.Weekly[wd] = distinct
|
|
}
|
|
}
|
|
for _, a := range p.All {
|
|
if span[a.Key] >= EverydaySpan {
|
|
p.Everyday = append(p.Everyday, a)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// canonicalize maps a fact key onto the key the profile counts it under.
|
|
// Lowercased, trimmed, and separators folded to "_" before the alias lookup,
|
|
// so "Выпил воды", "выпил-воды" and "выпил_воды" are one habit and not three.
|
|
// An unlisted key counts as itself.
|
|
func canonicalize(key string) string {
|
|
k := strings.ToLower(strings.TrimSpace(key))
|
|
k = strings.NewReplacer(" ", "_", "-", "_").Replace(k)
|
|
k = strings.Trim(k, "_")
|
|
if c, ok := canonicalKey[k]; ok {
|
|
return c
|
|
}
|
|
return k
|
|
}
|
|
|
|
func nonBehavioural(key string) bool {
|
|
if nonBehaviouralKeys[key] {
|
|
return true
|
|
}
|
|
for _, p := range nonBehaviouralKeyPrefixes {
|
|
if strings.HasPrefix(key, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// minutesPerDay — the modulus every clock time is taken in.
|
|
const minutesPerDay = 24 * 60
|
|
|
|
// maxTypicalSpread — how far apart the observations of one activity may sit,
|
|
// once rotated onto the shortest arc, before "usually at X" stops being a
|
|
// claim about anything. Half a day: wider than that and the values cover the
|
|
// clock, so no point on it is typical.
|
|
const maxTypicalSpread = minutesPerDay / 2
|
|
|
|
// circularMedianMinutes — the median time of day, on a clock rather than on a
|
|
// number line. Reports (0, false) when the values are too spread out to have a
|
|
// middle.
|
|
//
|
|
// A plain median of minutes-since-midnight is wrong for anything that straddles
|
|
// midnight, which is exactly the activity most likely to: bedtimes of 23:40,
|
|
// 23:50, 00:10 and 00:20 average out to 720 minutes, and she says "обычно ты
|
|
// спишь около 12:00". The fix is to find the rotation of the sorted values with
|
|
// the shortest span — the arc the observations actually occupy — take the
|
|
// ordinary median inside it, and wrap the answer back into the day.
|
|
func circularMedianMinutes(xs []int) (int, bool) {
|
|
if len(xs) == 0 {
|
|
return 0, false
|
|
}
|
|
s := make([]int, len(xs))
|
|
for i, x := range xs {
|
|
s[i] = ((x % minutesPerDay) + minutesPerDay) % minutesPerDay
|
|
}
|
|
sort.Ints(s)
|
|
|
|
// Each rotation cuts the day at one observation and unwraps the values
|
|
// before the cut onto the following day. The cut with the smallest span is
|
|
// the one where no observation is on the far side of midnight from the rest.
|
|
best, bestSpan := 0, minutesPerDay+1
|
|
for i := range s {
|
|
span := s[(i+len(s)-1)%len(s)] - s[i]
|
|
if i > 0 {
|
|
span += minutesPerDay
|
|
}
|
|
if span < bestSpan {
|
|
best, bestSpan = i, span
|
|
}
|
|
}
|
|
if bestSpan > maxTypicalSpread {
|
|
return 0, false
|
|
}
|
|
rot := make([]int, 0, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
v := s[(best+i)%len(s)]
|
|
if best+i >= len(s) {
|
|
v += minutesPerDay
|
|
}
|
|
rot = append(rot, v)
|
|
}
|
|
m := medianInt(rot) % minutesPerDay
|
|
return m, true
|
|
}
|
|
|
|
// medianInt — the middle value, averaging the two middles on an even count.
|
|
func medianInt(xs []int) int {
|
|
if len(xs) == 0 {
|
|
return 0
|
|
}
|
|
s := make([]int, len(xs))
|
|
copy(s, xs)
|
|
sort.Ints(s)
|
|
mid := len(s) / 2
|
|
if len(s)%2 == 1 {
|
|
return s[mid]
|
|
}
|
|
return (s[mid-1] + s[mid]) / 2
|
|
}
|
|
|
|
// weekdayRU and activityRU are loaded from the embedded behavior_ru.json;
|
|
// see behavior_ru.go.
|
|
|
|
// FormatWeekdayRU reads back what DISTINGUISHES a given weekday.
|
|
// Second person singular and informal, as she speaks TO him.
|
|
//
|
|
// When nothing distinguishes it, she says so and names the daily habits as
|
|
// daily habits instead of passing them off as an answer about that day. The
|
|
// previous version had no such distinction and answered "что я делаю по
|
|
// субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday
|
|
// were the reason.
|
|
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
|
day := weekdayRU[int(wd)%7]
|
|
acts := p.Weekly[wd]
|
|
if len(acts) > 0 {
|
|
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
|
|
}
|
|
if len(p.Everyday) > 0 {
|
|
return say.S(say.HabitWeekdaySame, map[string]string{
|
|
"day": day, "items": joinActivities(p.Everyday),
|
|
})
|
|
}
|
|
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
|
|
}
|
|
|
|
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
|
//
|
|
// The two days are answered separately rather than pooled: "по выходным" is a
|
|
// question about both, and a habit he has on Saturdays only is the interesting
|
|
// half of the answer, not noise to average away.
|
|
func (p Profile) FormatWeekendRU() string {
|
|
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
|
switch {
|
|
case len(sat) > 0 && len(sun) > 0:
|
|
return say.S(say.HabitWeekendBoth, map[string]string{
|
|
"sat": joinActivities(sat), "sun": joinActivities(sun),
|
|
})
|
|
case len(sat) > 0:
|
|
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
|
case len(sun) > 0:
|
|
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
|
case len(p.Everyday) > 0:
|
|
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
|
}
|
|
return say.S(say.HabitWeekendNone, nil)
|
|
}
|
|
|
|
// FormatOverallRU reads back the habits that hold across the whole week, and
|
|
// says over what stretch of records it is claiming them.
|
|
//
|
|
// The period is spoken because "обычно" without one is an unfalsifiable claim
|
|
// about his life: the same sentence comes out of three days of taps and out of
|
|
// a year of them, and only one of those is worth believing.
|
|
func (p Profile) FormatOverallRU() string {
|
|
if len(p.All) == 0 {
|
|
return say.S(say.HabitOverallNone, nil)
|
|
}
|
|
return say.S(say.HabitOverall, map[string]string{
|
|
"items": joinActivities(p.All), "span": p.spanRU(),
|
|
})
|
|
}
|
|
|
|
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
|
// is too short to name in days.
|
|
func (p Profile) spanRU() string {
|
|
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
|
return say.S(say.HabitSpanToday, nil)
|
|
}
|
|
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
|
return say.S(say.HabitSpanDays, map[string]string{
|
|
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
|
|
})
|
|
}
|
|
|
|
// pluralDaysRU — the Russian count form of "день" for n.
|
|
func pluralDaysRU(n int) string {
|
|
switch {
|
|
case n%100 >= 11 && n%100 <= 14:
|
|
return "дней"
|
|
case n%10 == 1:
|
|
return "день"
|
|
case n%10 >= 2 && n%10 <= 4:
|
|
return "дня"
|
|
default:
|
|
return "дней"
|
|
}
|
|
}
|
|
|
|
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
|
// not an answer; the most established few are.
|
|
const maxRecited = 5
|
|
|
|
func joinActivities(acts []Activity) string {
|
|
if len(acts) > maxRecited {
|
|
acts = acts[:maxRecited]
|
|
}
|
|
parts := make([]string, len(acts))
|
|
for i, a := range acts {
|
|
gloss, ok := activityRU[a.Key]
|
|
if !ok {
|
|
// No gloss: quote the key instead of reading it as a verb. The keys
|
|
// come from the model, so an unglossed one is as likely to be
|
|
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
|
// not a sentence.
|
|
gloss = say.S(say.HabitUnglossed, map[string]string{
|
|
"key": strings.ReplaceAll(a.Key, "_", " "),
|
|
})
|
|
}
|
|
if !a.HasTypical {
|
|
parts[i] = gloss
|
|
continue
|
|
}
|
|
parts[i] = say.S(say.HabitAt, map[string]string{
|
|
"gloss": gloss,
|
|
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
|
|
})
|
|
}
|
|
if len(parts) == 1 {
|
|
return parts[0]
|
|
}
|
|
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
|
|
}
|