diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c36682d..4b61170 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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 } diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 0bc40b8..308c362 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -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) + } +} diff --git a/cmd/mavend/memoryeval.go b/cmd/mavend/memoryeval.go index 2faea11..7a8f2d7 100644 --- a/cmd/mavend/memoryeval.go +++ b/cmd/mavend/memoryeval.go @@ -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, diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 30cc4ab..6baed33 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -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) diff --git a/internal/ipc/client.go b/internal/ipc/client.go index fae2f4d..f048ffb 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -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 { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index cee5355..887e2fc 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -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 { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 20e1b9f..bdbd631 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -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 } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index fc0d7f5..edb8d45 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -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" diff --git a/internal/memeval/eval.go b/internal/memeval/eval.go index 1edb5f5..d1eb3ad 100644 --- a/internal/memeval/eval.go +++ b/internal/memeval/eval.go @@ -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 } } diff --git a/internal/memeval/eval_test.go b/internal/memeval/eval_test.go index 93979ee..bcd794d 100644 --- a/internal/memeval/eval_test.go +++ b/internal/memeval/eval_test.go @@ -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) + } +} diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 7356f44..8ed3260 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -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) diff --git a/internal/memory/behavior_ru.go b/internal/memory/behavior_ru.go index 6354e93..49fbe65 100644 --- a/internal/memory/behavior_ru.go +++ b/internal/memory/behavior_ru.go @@ -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 + } + } } diff --git a/internal/memory/behavior_ru.json b/internal/memory/behavior_ru.json index 8bdfcdd..a53147c 100644 --- a/internal/memory/behavior_ru.json +++ b/internal/memory/behavior_ru.json @@ -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"] } } diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index d257667..e2ee61c 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -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) + } +} diff --git a/internal/router/calendar.go b/internal/router/calendar.go index 3f86976..e6cf6bf 100644 --- a/internal/router/calendar.go +++ b/internal/router/calendar.go @@ -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 { diff --git a/internal/router/habit.go b/internal/router/habit.go index 92add2c..f9e25d4 100644 --- a/internal/router/habit.go +++ b/internal/router/habit.go @@ -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 } diff --git a/internal/router/habit_test.go b/internal/router/habit_test.go index 8f868aa..14af976 100644 --- a/internal/router/habit_test.go +++ b/internal/router/habit_test.go @@ -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) + } + } +} diff --git a/internal/store/facts.go b/internal/store/facts.go index d6590bf..3ff9075 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -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_. // diff --git a/internal/store/notes.go b/internal/store/notes.go index b2b4179..a4770bd 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -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) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3a46f99..600e526 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) + } +}