Merge branch 'fix/g03' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:18:10 +04:00
20 changed files with 783 additions and 57 deletions
+16 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/weather"
)
@@ -177,9 +178,19 @@ func isRestOfDayQuery(text string) bool {
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
}
// habitFactWindow — how many recent facts the behaviour profile is counted
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
// over. Enough for a season of habits without scanning the whole store on every
// question; the profile is recomputed on read, so the bound is the cost control.
//
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
// was a plain recent-facts read the window was a row budget over every writer,
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
// 2000 rows was under three days of history. A weekday habit needs
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
// store with a year of taps in it. Self facts come from voice taps, and he does
// not tap seven hundred times a day.
const habitFactWindow = 2000
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
@@ -190,7 +201,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
if !ok {
return "", false
}
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
if err != nil {
log.Printf("voice: habits: recent facts: %v", err)
return "не получилось посмотреть записи.", true
@@ -203,6 +214,9 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
if q.HasWeekday {
return profile.FormatWeekdayRU(q.Weekday), true
}
if q.Weekend {
return profile.FormatWeekendRU(), true
}
return profile.FormatOverallRU(), true
}
+45 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// planAPI answers only DayPlan; every other call is unimplemented, which is
@@ -142,17 +143,21 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
}
}
// habitAPI answers only RecentFacts — the whole input the behaviour profile
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
// habitAPI answers only the kind-filtered fact read — the whole input the
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
// profile must not read the mixed window, and a caller that does fails here.
type habitAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
err error
calls int
kind string
}
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
a.calls++
a.kind = kind
return a.facts, a.err
}
@@ -225,3 +230,40 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
}
}
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
// so it must be spent on the rows the profile can use. Reading the mixed table
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
// every tap out of the window, and she then reported no habits on a store that
// held them.
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
now := planDay()
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
if _, ok := h.queryHabits(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
}); !ok {
t.Fatal("the habit source must claim a habit question")
}
if api.kind != string(store.KindSelf) {
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
}
}
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
// matchers: a habit question carrying "планы" used to be answered by the day
// plan with today's calendar, because day-plan sits above habits.
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
now := planDay()
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
reply := h.actionQuery(context.Background(), router.Decision{
Intent: router.IntentQuery,
Utterance: "какие у меня обычно планы по вторникам?",
})
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
t.Errorf("reply = %q, want %q", reply, want)
}
}
+12 -3
View File
@@ -21,6 +21,17 @@ import (
"github.com/kami/maven/internal/store"
)
// memoryEvalTimeout — the per-request deadline on one evaluation.
//
// It used to be five minutes, on the grounds that nobody waits for the answer.
// Nobody waits for the evaluation, but there is ONE resident model behind one
// llama-server, so a voice turn that arrives mid-evaluation waits behind it:
// five minutes of evaluation is five minutes of a mute assistant. Sixty seconds
// is long enough for a Thinking model on this prompt and short enough that the
// worst collision is one turn answered late rather than a turn abandoned. An
// evaluation cut off here costs nothing: it is retried at the next interval.
const memoryEvalTimeout = 60 * time.Second
// memoryEvalWorker — ticker + evaluator.
type memoryEvalWorker struct {
eval *memeval.Evaluator
@@ -47,9 +58,7 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
if interval <= 0 {
interval = config.DefaultMemoryEvalInterval
}
// A generous per-request timeout: this is a long prompt to a Thinking model
// and nobody is waiting on the answer.
client := llmClientFor(lp, 5*time.Minute)
client := llmClientFor(lp, memoryEvalTimeout)
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
MaxItems: cfg.MemoryEval.MaxItems,
MinConfidence: cfg.MemoryEval.MinConfidence,
+5
View File
@@ -439,6 +439,10 @@ type outcomesReq struct {
type nReq struct {
N int `json:"n"`
}
type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
}
type calendarEventsReq struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
@@ -586,6 +590,7 @@ type CoreAPI interface {
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
+9
View File
@@ -62,6 +62,7 @@ var readOnlyMethods = map[Method]bool{
MethodListReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodQueryNotes: true,
@@ -334,6 +335,14 @@ func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, nil
}
func (c *Client) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
var out []Fact
if err := c.call(ctx, MethodRecentActiveFacts, kindNReq{Kind: kind, N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
var out []Fact
if err := c.call(ctx, MethodCalendarEvents, calendarEventsReq{From: from, To: to}, &out); err != nil {
+22
View File
@@ -120,6 +120,18 @@ func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
@@ -764,6 +776,16 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodRecentActiveFacts: withParams(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
out, err := api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []Fact{}
}
return out, nil
}),
MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
out, err := api.CalendarEvents(ctx, p.From, p.To)
if err != nil {
+3
View File
@@ -62,6 +62,9 @@ func (UnimplementedCoreAPI) RecentOutcomes(ctx context.Context, rule string, n i
func (UnimplementedCoreAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
return nil, ErrNotImplemented
}
+1
View File
@@ -25,6 +25,7 @@ const (
MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes"
MethodRecentFacts Method = "recent_facts"
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges"
MethodWriteNote Method = "write_note"
+43 -28
View File
@@ -85,7 +85,13 @@ type Completer interface {
// no config facts.
type Reader interface {
RecentFacts(ctx context.Context, n int) ([]store.Fact, error)
RecentNotes(ctx context.Context, n int) ([]store.Note, error)
// RecentNotesExcludingSource feeds the snapshot, RecentNotesBySource feeds
// the dedupe. Both are source-scoped in SQL rather than filtered here: a
// plain RecentNotes read makes each window a budget over ALL note writers,
// so her own hourly observations shrink the snapshot and ordinary notes
// push old observations out of the dedupe set.
RecentNotesExcludingSource(ctx context.Context, source string, n int) ([]store.Note, error)
RecentNotesBySource(ctx context.Context, source string, n int) ([]store.Note, error)
RecentNudges(ctx context.Context, n int) ([]store.Nudge, error)
}
@@ -226,29 +232,44 @@ func formatNote(o Observation) string {
return fmt.Sprintf("%s [%s]", o.Text, o.Action)
}
// DedupeWindow — how many of her OWN past observations the dedupe looks back
// over. Wider than MaxItems because the point is to remember saying it, not to
// summarize it. Counted in eval notes only: when this was a plain recent-notes
// read the window was really a budget over every note writer, so a few hundred
// ordinary notes pushed an observation out of sight and the next evaluation was
// free to write the same sentence again.
const DedupeWindow = 200
// recordedTexts — the normalized text of every observation earlier evaluations
// wrote, for dedupe. Reads a wider window than MaxItems because the point is to
// remember saying it, not to summarize it.
// wrote, for dedupe.
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
notes, err := e.read.RecentNotes(ctx, 200)
notes, err := e.read.RecentNotesBySource(ctx, EvalNoteSource, DedupeWindow)
if err != nil {
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
}
seen := make(map[string]bool, len(notes))
for _, n := range notes {
if n.Source != EvalNoteSource {
continue
}
text := n.Text
// Strip the "[action]" suffix formatNote appended.
if i := strings.LastIndex(text, " ["); i > 0 && strings.HasSuffix(text, "]") {
text = text[:i]
}
seen[normalizeObservation(text)] = true
seen[normalizeObservation(stripAction(n.Text))] = true
}
return seen, nil
}
// actions — the suggested_action enum, as the grammar constrains it.
var actions = []string{"note", "propose", "notify"}
// stripAction removes the "[action]" suffix formatNote appended, and only that.
// Matching any bracketed tail would eat the end of an observation that happens
// to finish on a bracketed clause, which changes its dedupe key and lets the
// same sentence through twice.
func stripAction(text string) string {
for _, a := range actions {
if s, ok := strings.CutSuffix(text, " ["+a+"]"); ok {
return s
}
}
return text
}
// normalizeObservation — dedupe key. Case- and whitespace-insensitive, which
// catches the realistic repeat (the model re-emitting the same sentence with a
// different comma) without pretending to do semantic dedupe.
@@ -259,16 +280,19 @@ func normalizeObservation(s string) string {
// snapshot renders recent memory as the user turn. Returns "" when there is
// nothing in any store — the caller treats that as "do not ask the model".
//
// Notes written by earlier evaluations are excluded. Feeding her own
// Notes written by earlier evaluations are excluded, in SQL. Feeding her own
// observations back in is how "я заметила, что ты не записывал еду" becomes
// evidence for noticing it again, three evaluations deep.
// evidence for noticing it again, three evaluations deep. Excluding them after
// the read was worse than not excluding them: hourly evaluation makes her own
// notes the majority of the newest rows within weeks, so asking for MaxItems
// and dropping hers left the model a handful of real notes to look at.
func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
n := e.cfg.MaxItems
facts, err := e.read.RecentFacts(ctx, n)
if err != nil {
return "", fmt.Errorf("memory eval: recent facts: %w", err)
}
notes, err := e.read.RecentNotes(ctx, n)
notes, err := e.read.RecentNotesExcludingSource(ctx, EvalNoteSource, n)
if err != nil {
return "", fmt.Errorf("memory eval: recent notes: %w", err)
}
@@ -286,19 +310,10 @@ func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
wrote = true
}
}
own := 0
var noteLines []string
for _, nt := range notes {
if nt.Source == EvalNoteSource {
own++
continue
}
noteLines = append(noteLines, fmt.Sprintf("- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160)))
}
if len(noteLines) > 0 {
if len(notes) > 0 {
b.WriteString("\nЗаметки:\n")
for _, l := range noteLines {
b.WriteString(l)
for _, nt := range notes {
fmt.Fprintf(&b, "- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160))
wrote = true
}
}
+93
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -269,3 +270,95 @@ func TestParseObservationsTolerantAndBounded(t *testing.T) {
t.Fatalf("parsed %d observations, want the %d cap", len(obs), MaxObservations)
}
}
// TestDedupeSurvivesOrdinaryNotes — the dedupe window is a count of HER notes,
// not of all notes. With a plain recent-notes read, DedupeWindow ordinary notes
// written after an observation pushed it out of sight and the same sentence was
// written again on the next evaluation.
func TestDedupeSurvivesOrdinaryNotes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedMemory(t, st, ctx, now)
same := `[{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"note"}]`
f := &fakeLLM{replies: []string{same, same}}
ev := NewEvaluator(st, st, f, Config{})
if _, err := ev.Evaluate(ctx, now); err != nil {
t.Fatalf("Evaluate: %v", err)
}
// A few months of ordinary use between the two evaluations.
for i := 0; i < DedupeWindow*2; i++ {
if _, err := st.WriteNote(ctx, now.Add(time.Duration(i+1)*time.Minute),
fmt.Sprintf("обычная заметка %d", i), nil, "tap:voice"); err != nil {
t.Fatalf("write note: %v", err)
}
}
if _, err := ev.Evaluate(ctx, now.Add(24*time.Hour)); err != nil {
t.Fatalf("Evaluate: %v", err)
}
own, err := st.RecentNotesBySource(ctx, EvalNoteSource, 100)
if err != nil {
t.Fatalf("RecentNotesBySource: %v", err)
}
if len(own) != 1 {
t.Fatalf("eval notes = %d, want 1 — the repeat was not deduped", len(own))
}
}
// TestSnapshotBudgetIsNotEatenByOwnNotes — MaxItems notes must be MaxItems of
// HIS notes. Reading MaxItems rows and then discarding hers left the model a
// handful of real notes once hourly evaluation had run for a few weeks.
func TestSnapshotBudgetIsNotEatenByOwnNotes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
// His notes first, then a long run of hers on top of them.
for i := 0; i < 5; i++ {
if _, err := st.WriteNote(ctx, now.Add(-time.Duration(100-i)*time.Hour),
fmt.Sprintf("его заметка %d", i), nil, "tap:voice"); err != nil {
t.Fatalf("write note: %v", err)
}
}
for i := 0; i < 50; i++ {
if _, err := st.WriteNote(ctx, now.Add(-time.Duration(50-i)*time.Hour),
fmt.Sprintf("я заметила кое-что %d [note]", i), nil, EvalNoteSource); err != nil {
t.Fatalf("write note: %v", err)
}
}
f := &fakeLLM{replies: []string{"[]"}}
ev := NewEvaluator(st, st, f, Config{MaxItems: 5})
if _, err := ev.Evaluate(ctx, now); err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(f.calls) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(f.calls))
}
prompt := f.calls[0].User
if strings.Contains(prompt, "я заметила") {
t.Errorf("her own observations reached the prompt:\n%s", prompt)
}
for i := 0; i < 5; i++ {
if !strings.Contains(prompt, fmt.Sprintf("его заметка %d", i)) {
t.Errorf("his note %d missing from the prompt:\n%s", i, prompt)
}
}
}
// TestStripActionKeepsBracketedTail — the dedupe key strips the recorded
// action and nothing else. Cutting at the last " [" ate the end of an
// observation that itself ends on a bracketed clause, so the same sentence
// hashed two ways.
func TestStripActionKeepsBracketedTail(t *testing.T) {
text := "ты не пил воду [со вторника]"
if got := stripAction(formatNote(Observation{Text: text, Action: "note"})); got != text {
t.Errorf("stripAction = %q, want %q", got, text)
}
if got := stripAction(text); got != text {
t.Errorf("stripAction = %q, want it untouched", got)
}
}
+166 -15
View File
@@ -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,68 @@ 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.
// 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 fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
joinActivities(sat), joinActivities(sun))
case len(sat) > 0:
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
joinActivities(sat))
case len(sun) > 0:
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
joinActivities(sun))
case len(p.Everyday) > 0:
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
joinActivities(p.Everyday))
}
return "по выходным я пока не вижу у тебя ничего постоянного."
}
// 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 +419,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)
+13 -3
View File
@@ -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
}
}
}
+19 -2
View File
@@ -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"]
}
}
+160
View File
@@ -207,3 +207,163 @@ 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)
}
}
}
// "по выходным" is a question about two days, answered as two days.
func TestFormatWeekendRU(t *testing.T) {
now := behaviorNow()
obs := append(
habitHistory("workout", time.Saturday, 11, 0, 3, now),
habitHistory("walk", time.Sunday, 15, 0, 3, now)...,
)
got := BuildProfile(obs, now).FormatWeekendRU()
if !strings.Contains(got, "по субботам") || !strings.Contains(got, "по воскресеньям") {
t.Errorf("got %q, want both weekend days named", got)
}
empty := BuildProfile(nil, now).FormatWeekendRU()
if strings.Contains(empty, "у меня") {
t.Errorf("got %q", empty)
}
}
+9
View File
@@ -45,6 +45,15 @@ var otherDayWords = []string{
// сегодня?" and a plan that hijacks every date-bearing question would bury the
// events under checklist lines. Only a plan-shaped ask, and only about today.
func IsDayPlanQuery(text string) bool {
// A habit question is never a day plan, whatever words it shares with one.
// "какие у меня обычно планы по вторникам?" carries "планы", so the plan
// source claimed it and answered today's calendar stamped with today's
// date, and the habit source never ran. Deciding it here rather than by
// reordering the source table keeps one matcher from depending on the
// other's position in a slice.
if _, ok := ParseHabitQuery(text); ok {
return false
}
toks := planTokens(text)
for _, t := range toks {
for _, w := range otherDayWords {
+15
View File
@@ -11,9 +11,11 @@ import "time"
// HabitQuery — a parsed "what do I usually do" question. Weekday is set only
// when the utterance names one; otherwise the answer covers the whole week.
// Weekend is set for "по выходным", which names two days rather than one.
type HabitQuery struct {
Weekday time.Weekday
HasWeekday bool
Weekend bool
}
// habitMarkers — the words that make a question about habit rather than about
@@ -35,6 +37,8 @@ var weekdayWords = map[string]time.Weekday{
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
"воскресенья": time.Sunday, "воскресенью": time.Sunday,
"воскресеньем": time.Sunday, "воскресеньях": time.Sunday,
"monday": time.Monday, "mondays": time.Monday,
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
@@ -44,6 +48,14 @@ var weekdayWords = map[string]time.Weekday{
"sunday": time.Sunday, "sundays": time.Sunday,
}
// weekendWords — the weekend as one unit. "что я обычно делаю по выходным?"
// has a habit marker and names days, but no weekday name is in it, so it used
// to fall through to the whole-week profile and answer about Tuesdays too.
var weekendWords = map[string]bool{
"выходным": true, "выходные": true, "выходных": true, "выходной": true,
"weekend": true, "weekends": true,
}
// ParseHabitQuery reports whether an utterance asks what the owner usually
// does, and on which weekday if it names one.
//
@@ -68,6 +80,9 @@ func ParseHabitQuery(text string) (HabitQuery, bool) {
if wd, ok := weekdayWords[t]; ok {
return HabitQuery{Weekday: wd, HasWeekday: true}, true
}
if weekendWords[t] {
return HabitQuery{Weekend: true}, true
}
}
return HabitQuery{}, true
}
+45
View File
@@ -43,3 +43,48 @@ func TestParseHabitQuery(t *testing.T) {
}
}
}
// TestHabitQueryBeatsDayPlan — "какие у меня обычно планы по вторникам?" is a
// habit question that happens to carry a plan word. The day plan claimed it
// first and answered today's calendar stamped with today's date, and the habit
// source never ran.
func TestHabitQueryBeatsDayPlan(t *testing.T) {
for _, q := range []string{
"какие у меня обычно планы по вторникам?",
"что обычно по плану в среду?",
"какие планы обычно по выходным?",
} {
if IsDayPlanQuery(q) {
t.Errorf("%q was claimed as a day plan", q)
}
if _, ok := ParseHabitQuery(q); !ok {
t.Errorf("%q is not parsed as a habit question", q)
}
}
// A plan question without a habit marker still belongs to the day plan.
for _, q := range []string{"какие планы на сегодня?", "что у меня по плану?"} {
if !IsDayPlanQuery(q) {
t.Errorf("%q must still be a day plan", q)
}
}
}
// TestHabitQueryWeekendAndSundayForms — "по выходным" names days but no
// weekday, so it used to be answered with the whole-week profile. Sunday had
// only its dative plural listed.
func TestHabitQueryWeekendAndSundayForms(t *testing.T) {
q, ok := ParseHabitQuery("что я обычно делаю по выходным?")
if !ok || !q.Weekend || q.HasWeekday {
t.Errorf("weekend query parsed as %+v (ok=%v)", q, ok)
}
for _, s := range []string{
"что я обычно делаю в воскресенье?",
"чем я обычно занят по воскресеньям?",
"что обычно бывает в воскресенья?",
} {
q, ok := ParseHabitQuery(s)
if !ok || !q.HasWeekday || q.Weekday != time.Sunday {
t.Errorf("%q parsed as %+v (ok=%v)", s, q, ok)
}
}
}
+39
View File
@@ -82,6 +82,45 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, rows.Err()
}
// RecentActiveFactsByKind — the newest n facts of one kind, newest first, with
// the retracted ones left out. "Active" means two exclusions: a row some later
// row voids, and the void marker VoidLatestFact writes to retract it. A
// correction still counts, because a correction is a value he stands behind.
//
// It exists because the facts table is shared and the noisy writers are not the
// interesting ones. A caller that reads n recent rows and then keeps the self
// ones has a window whose real length is set by how often the pollers write:
// one WireGuard peer alone rehandshakes every couple of minutes, which is
// enough env rows to reduce a 2000-row window to under three days. Filtering in
// SQL makes the bound mean what the caller thinks it means.
//
// Voided rows are excluded here and included by RecentFacts on purpose. The
// dash shows the audit trail, because you want to SEE a correction. A reader
// that asks what he usually does must not count something he explicitly took
// back.
func (s *Store) RecentActiveFactsByKind(ctx context.Context, kind FactKind, n int) ([]Fact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE kind = ?
AND NOT (voids_id IS NOT NULL AND value = '"voided"')
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT ?`, string(kind), n)
if err != nil {
return nil, fmt.Errorf("recent facts by kind: %w", err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// CalendarEvents returns calendar facts whose key date falls within [from, to).
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
//
+22 -1
View File
@@ -80,8 +80,29 @@ func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]N
// embedding math; Score stays 0). This is the read surface for /dash: notes
// captured by voice are otherwise only reachable through semantic query.
func (s *Store) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return s.recentNotesWhere(ctx, "", n)
}
// RecentNotesBySource returns the newest n notes written by one source, newest
// first. The filter is in SQL, not in the caller, because a caller that reads n
// rows and then keeps the ones it wants has a window measured in OTHER writers'
// traffic: once n newer notes from anywhere have landed, the rows it was
// looking for are gone. Anything that needs "the last n of mine" wants this.
func (s *Store) RecentNotesBySource(ctx context.Context, source string, n int) ([]Note, error) {
return s.recentNotesWhere(ctx, "WHERE source = ?", n, source)
}
// RecentNotesExcludingSource returns the newest n notes NOT written by source.
// Same reasoning inverted: a reader that wants n notes he wrote must not have
// its budget eaten by rows it is about to discard.
func (s *Store) RecentNotesExcludingSource(ctx context.Context, source string, n int) ([]Note, error) {
return s.recentNotesWhere(ctx, "WHERE source <> ?", n, source)
}
func (s *Store) recentNotesWhere(ctx context.Context, where string, n int, args ...any) ([]Note, error) {
args = append(args, n)
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, text, source FROM notes ORDER BY ts DESC LIMIT ?`, n)
`SELECT id, ts, text, source FROM notes `+where+` ORDER BY ts DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("recent notes: %w", err)
}
+46
View File
@@ -424,3 +424,49 @@ func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
t.Error("a non-calendar source must not be read as a calendar event")
}
}
// TestRecentActiveFactsByKind — the behaviour profile's window. Env rows must
// not spend it, and a retracted row must not be counted as something he did.
func TestRecentActiveFactsByKind(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
// A noisy env writer, the way mavpoll writes handshakes.
for i := 0; i < 50; i++ {
if _, err := s.WriteFact(ctx, now.Add(-time.Duration(i)*time.Minute), KindEnv,
"wg_handshake", "1", "poll:wireguard", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write env fact: %v", err)
}
}
if _, err := s.WriteFact(ctx, now.Add(-2*time.Hour), KindSelf, "workout", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, err := s.WriteFact(ctx, now.Add(-3*time.Hour), KindSelf, "walk", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, _, err := s.VoidLatestFact(ctx, "walk", "tap:voice", now.Add(-time.Hour)); err != nil {
t.Fatalf("VoidLatestFact: %v", err)
}
got, err := s.RecentActiveFactsByKind(ctx, KindSelf, 10)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d facts, want 1 (workout): %+v", len(got), got)
}
if got[0].Key != "workout" {
t.Errorf("key = %q, want workout", got[0].Key)
}
// The window is spent on self rows only: a limit smaller than the env
// traffic still returns the taps.
got, err = s.RecentActiveFactsByKind(ctx, KindSelf, 1)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 || got[0].Key != "workout" {
t.Fatalf("got %+v, want the newest self fact", got)
}
}