Version, authenticate and fully trace ecosystem calls #84
+142
-15
@@ -45,12 +45,19 @@ type Observation struct {
|
||||
//
|
||||
// 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.
|
||||
// воду утром" 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
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
HasTypical bool
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model.
|
||||
@@ -82,13 +89,20 @@ 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_",
|
||||
"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.
|
||||
@@ -113,7 +127,7 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
if o.Kind != "self" {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(o.Key)
|
||||
key := canonicalize(o.Key)
|
||||
if key == "" || nonBehavioural(key) {
|
||||
continue
|
||||
}
|
||||
@@ -151,11 +165,13 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
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(medianInt(b.mins)) * time.Minute,
|
||||
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
|
||||
@@ -208,7 +224,24 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
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
|
||||
@@ -217,6 +250,63 @@ func nonBehavioural(key string) bool {
|
||||
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 {
|
||||
@@ -253,15 +343,44 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
}
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
// 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 "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
|
||||
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), 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 "по записям за сегодня"
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return fmt.Sprintf("по записям за последние %d %s", days, 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
|
||||
@@ -276,7 +395,15 @@ func joinActivities(acts []Activity) string {
|
||||
for i, a := range acts {
|
||||
gloss, ok := activityRU[a.Key]
|
||||
if !ok {
|
||||
gloss = a.Key
|
||||
// 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 = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||
}
|
||||
if !a.HasTypical {
|
||||
parts[i] = gloss
|
||||
continue
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
|
||||
@@ -22,15 +22,18 @@ import (
|
||||
var behaviorRUJSON []byte
|
||||
|
||||
type behaviorRU struct {
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
KeyAliases map[string][]string `json:"key_aliases"`
|
||||
}
|
||||
|
||||
var (
|
||||
// weekdayRU — dative plural, indexed by time.Weekday.
|
||||
weekdayRU []string
|
||||
// activityRU — fact key to second-person-singular verb phrase.
|
||||
// activityRU — canonical fact key to second-person-singular verb phrase.
|
||||
activityRU map[string]string
|
||||
// canonicalKey — observed fact key to the canonical key it counts as.
|
||||
canonicalKey map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,4 +45,11 @@ func init() {
|
||||
panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays)))
|
||||
}
|
||||
weekdayRU, activityRU = v.Weekdays, v.Activities
|
||||
canonicalKey = make(map[string]string)
|
||||
for canonical, variants := range v.KeyAliases {
|
||||
canonicalKey[canonical] = canonical
|
||||
for _, variant := range variants {
|
||||
canonicalKey[variant] = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
"plural, because both 'по вторникам' and 'по средам' need that form.",
|
||||
"",
|
||||
"activities glosses a fact key as a second-person-singular verb phrase. An",
|
||||
"unknown key is read back verbatim rather than guessed at: inventing a",
|
||||
"Russian phrase for a key maven does not recognise puts words in his mouth."
|
||||
"unknown key is not glossed and is quoted rather than guessed at: inventing",
|
||||
"a Russian phrase for a key maven does not recognise puts words in his",
|
||||
"mouth, and reading the raw key inline produces 'обычно ты выпил_воды'.",
|
||||
"",
|
||||
"key_aliases maps the fact keys the router actually emits onto the canonical",
|
||||
"key the profile counts. The key of a self fact comes straight out of the",
|
||||
"LLM with no allowlist behind it, so 'я выпил воду' and 'попил воды' arrive",
|
||||
"as different keys, split one habit into two, and drop both below the",
|
||||
"day threshold. Additive and lowercase; an unlisted key counts as itself."
|
||||
],
|
||||
"weekdays": [
|
||||
"воскресеньям",
|
||||
@@ -30,5 +37,15 @@
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься"
|
||||
},
|
||||
"key_aliases": {
|
||||
"water": ["вода", "воду", "воды", "водичка", "попил", "попил_воды", "выпил_воды", "выпил_воду", "drink_water", "drank_water"],
|
||||
"meal": ["еда", "еду", "поел", "поесть", "завтрак", "обед", "ужин", "food", "ate", "breakfast", "lunch", "dinner"],
|
||||
"sleep": ["сон", "спать", "лег", "лёг", "уснул", "заснул", "bedtime", "slept"],
|
||||
"break": ["перерыв", "отдых", "пауза", "rest", "pause"],
|
||||
"shower": ["душ", "принял_душ", "помылся", "мытьё", "мылся"],
|
||||
"walk": ["прогулка", "гулял", "погулял", "выгулял", "walked", "walking"],
|
||||
"pills": ["витамины", "таблетки", "таблетка", "лекарство", "vitamins", "meds", "medicine"],
|
||||
"workout": ["тренировка", "тренировался", "потренировался", "зал", "спорт", "exercise", "gym", "training"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,3 +207,146 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypicalTimeIsCircular — the median is over a clock, not a number line.
|
||||
// Bedtimes either side of midnight used to average to midday, which is the
|
||||
// exact error the median was chosen to avoid, on the one activity most likely
|
||||
// to cross the boundary.
|
||||
func TestTypicalTimeIsCircular(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()),
|
||||
Key: "sleep",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
got := p.All[0].TypicalAt
|
||||
if !p.All[0].HasTypical {
|
||||
t.Fatal("a four-observation cluster has a typical time")
|
||||
}
|
||||
if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute {
|
||||
t.Errorf("typical bedtime = %v, want just either side of midnight", got)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") {
|
||||
t.Errorf("read back as %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Times spread across the whole clock have no typical value, and she must not
|
||||
// name one.
|
||||
func TestNoTypicalTimeWhenSpreadWide(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, hh := range []int{2, 9, 16, 21} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Key: "water",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
if p.All[0].HasTypical {
|
||||
t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около") {
|
||||
t.Errorf("read back with a time she cannot support: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM
|
||||
// with no allowlist behind it, so the same habit arrives spelled several ways.
|
||||
// Counted separately, each spelling sits below MinHabitDays and the habit
|
||||
// vanishes.
|
||||
func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()),
|
||||
Key: key,
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All)
|
||||
}
|
||||
if p.All[0].Key != "water" || p.All[0].Days != 4 {
|
||||
t.Errorf("got %+v, want water on 4 days", p.All[0])
|
||||
}
|
||||
if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") {
|
||||
t.Errorf("read back as %q, want the glossed canonical key", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около
|
||||
// 09:00" is what reciting the raw key produced.
|
||||
func TestUnglossedKeyIsQuoted(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "отмечаешь «починил кран»") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// She says over what stretch of records "обычно" is claimed. Without it the
|
||||
// same sentence comes out of three days and out of a year.
|
||||
func TestOverallNamesThePeriod(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "по записям за последние 21 день") {
|
||||
t.Errorf("got %q, want the period spoken", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||
p := BuildProfile(nil, behaviorNow())
|
||||
got := p.FormatWeekdayRU(time.Wednesday)
|
||||
if strings.Contains(got, "у меня") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "у тебя") {
|
||||
t.Errorf("got %q, want an answer about him", got)
|
||||
}
|
||||
}
|
||||
|
||||
// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix
|
||||
// it silently swallowed any future self-fact key starting with those letters.
|
||||
func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want the key counted", p.All)
|
||||
}
|
||||
p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 0 {
|
||||
t.Fatalf("got %+v, want maven's own tuning state dropped", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralDaysRU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := pluralDaysRU(c.n); got != c.want {
|
||||
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user