diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c36682d..30d94be 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 diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 0bc40b8..e909c86 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,23 @@ 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) + } +} 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/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/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) + } +}