From 88c841cb0ea661b6be4717e288990aa226a017df Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 13:57:26 +0400 Subject: [PATCH 1/4] memeval: scope the evaluator's note windows by source Both windows the evaluator keeps over the notes table were row budgets over every writer. The dedupe read 200 recent notes and kept the eval ones, so after 200 ordinary notes an old observation left the window and the next evaluation wrote the same sentence again. The snapshot asked for MaxItems notes and then discarded her own, so once hourly evaluation had run for a few weeks the model saw almost no real notes. Both reads are now filtered in SQL, by RecentNotesBySource and RecentNotesExcludingSource. Two smaller things in the same area. The dedupe key stripped any trailing bracketed clause, so an observation ending in one hashed differently from its stored form; it now strips only the recorded action. The evaluation timeout was five minutes on the one llama-server that also answers voice turns, which made a collision a five-minute mute assistant, and is now sixty seconds. Found in review of #55. --- cmd/mavend/memoryeval.go | 15 ++++-- internal/memeval/eval.go | 71 +++++++++++++++----------- internal/memeval/eval_test.go | 93 +++++++++++++++++++++++++++++++++++ internal/store/notes.go | 23 ++++++++- models/stt | 1 + models/tts | 1 + 6 files changed, 172 insertions(+), 32 deletions(-) create mode 120000 models/stt create mode 120000 models/tts 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/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/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/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file From 012bdcc1ae69cf1fbe533da7a0523c597e5568f7 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:00:46 +0400 Subject: [PATCH 2/4] memory: count habits over self facts only, and skip retracted ones The behaviour profile read the newest 2000 rows of the shared facts table and then discarded everything that was not kind=self, so the length of the window was set by the noisiest writer. mavpoll writes a wg_handshake row every time a peer rehandshakes, about every two minutes per peer, which is enough to reduce 2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so that window can never hold one, and she answered that she knows no habits on a store holding a year of taps. RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row voids along with the void marker itself. The old read counted both a retracted tap and its retraction, so a fact he explicitly took back still shaped what she said he usually does. A correction still counts, because a correction is a value he stands behind. Found in review of #59. --- cmd/mavend/actions_query.go | 15 ++++++++++-- cmd/mavend/dayplan_test.go | 31 ++++++++++++++++++++--- internal/ipc/api.go | 5 ++++ internal/ipc/client.go | 9 +++++++ internal/ipc/server.go | 22 +++++++++++++++++ internal/ipc/unimplemented.go | 3 +++ internal/ipc/wire.go | 1 + internal/store/facts.go | 39 +++++++++++++++++++++++++++++ internal/store/store_test.go | 46 +++++++++++++++++++++++++++++++++++ 9 files changed, 166 insertions(+), 5 deletions(-) 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) + } +} From ba33a677f84f64135b623f5756aebce97bd7d62a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:04:24 +0400 Subject: [PATCH 3/4] memory: canonicalise habit keys, take the median on a clock, name the period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in how the counted profile is read back. The counting unit was the key the LLM invented. There is no allowlist and no normalization behind it, so "я выпил воду" and "попил воды" landed as different keys, split one habit into two, and dropped both below the two-day threshold. Keys now go through an alias table in behavior_ru.json before they are counted. An unglossed key is quoted rather than recited as a verb, because "обычно ты выпил_воды около 09:00" is not a sentence. The typical time was a median of minutes since midnight, which is wrong for anything that straddles midnight. Bedtimes of 23:40, 23:50, 00:10 and 00:20 gave 12:00, on the one activity most likely to cross the boundary. It is now a circular median, and when the observations span more than half the clock she names the habit without a time instead of inventing one. The rest is wording. Profile.Since was computed and never spoken, so "обычно" was an unfalsifiable claim; the overall read-back now says over how many days of records it holds. The no-data weekday answer said "у меня пока нет ничего постоянного" about a question concerning him. And "quiet" was a bare prefix in the non-behavioural list, so any future self-fact key starting with those five letters would have been dropped. Found in review of #59. --- internal/memory/behavior.go | 157 ++++++++++++++++++++++++++++--- internal/memory/behavior_ru.go | 16 +++- internal/memory/behavior_ru.json | 21 ++++- internal/memory/behavior_test.go | 143 ++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 20 deletions(-) diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 7356f44..692b2dc 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,44 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", day, joinActivities(p.Everyday)) } - return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) + return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) } -// FormatOverallRU reads back the habits that hold across the whole week. +// FormatOverallRU reads back the habits that hold across the whole week, and +// says over what stretch of records it is claiming them. +// +// The period is spoken because "обычно" without one is an unfalsifiable claim +// about his life: the same sentence comes out of three days of taps and out of +// a year of them, and only one of those is worth believing. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { return "я ещё не набрала достаточно записей, чтобы говорить о привычках." } - return fmt.Sprintf("обычно ты %s.", joinActivities(p.All)) + return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU()) +} + +// spanRU — "по записям за последние N дней", or a vaguer phrase when the window +// is too short to name in days. +func (p Profile) spanRU() string { + if p.Since.IsZero() || !p.Until.After(p.Since) { + return "по записям за сегодня" + } + days := int(p.Until.Sub(p.Since).Hours()/24) + 1 + return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days)) +} + +// pluralDaysRU — the Russian count form of "день" for n. +func pluralDaysRU(n int) string { + switch { + case n%100 >= 11 && n%100 <= 14: + return "дней" + case n%10 == 1: + return "день" + case n%10 >= 2 && n%10 <= 4: + return "дня" + default: + return "дней" + } } // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is @@ -276,7 +395,15 @@ func joinActivities(acts []Activity) string { for i, a := range acts { gloss, ok := activityRU[a.Key] if !ok { - gloss = a.Key + // No gloss: quote the key instead of reading it as a verb. The keys + // come from the model, so an unglossed one is as likely to be + // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is + // not a sentence. + gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " ")) + } + if !a.HasTypical { + parts[i] = gloss + continue } parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) 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..e70fe0d 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -207,3 +207,146 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) } } + +// TestTypicalTimeIsCircular — the median is over a clock, not a number line. +// Bedtimes either side of midnight used to average to midday, which is the +// exact error the median was chosen to avoid, on the one activity most likely +// to cross the boundary. +func TestTypicalTimeIsCircular(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()), + Key: "sleep", + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity", p.All) + } + got := p.All[0].TypicalAt + if !p.All[0].HasTypical { + t.Fatal("a four-observation cluster has a typical time") + } + if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute { + t.Errorf("typical bedtime = %v, want just either side of midnight", got) + } + if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") { + t.Errorf("read back as %q", s) + } +} + +// Times spread across the whole clock have no typical value, and she must not +// name one. +func TestNoTypicalTimeWhenSpreadWide(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, hh := range []int{2, 9, 16, 21} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()), + Key: "water", + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity", p.All) + } + if p.All[0].HasTypical { + t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt) + } + if s := p.FormatOverallRU(); strings.Contains(s, "около") { + t.Errorf("read back with a time she cannot support: %q", s) + } +} + +// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM +// with no allowlist behind it, so the same habit arrives spelled several ways. +// Counted separately, each spelling sits below MinHabitDays and the habit +// vanishes. +func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()), + Key: key, + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All) + } + if p.All[0].Key != "water" || p.All[0].Days != 4 { + t.Errorf("got %+v, want water on 4 days", p.All[0]) + } + if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") { + t.Errorf("read back as %q, want the glossed canonical key", s) + } +} + +// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около +// 09:00" is what reciting the raw key produced. +func TestUnglossedKeyIsQuoted(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now) + got := p.FormatOverallRU() + if !strings.Contains(got, "отмечаешь «починил кран»") { + t.Errorf("got %q", got) + } +} + +// She says over what stretch of records "обычно" is claimed. Without it the +// same sentence comes out of three days and out of a year. +func TestOverallNamesThePeriod(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now) + got := p.FormatOverallRU() + if !strings.Contains(got, "по записям за последние 21 день") { + t.Errorf("got %q, want the period spoken", got) + } +} + +// The no-data weekday answer is about him, not about her. "у меня пока нет +// ничего постоянного" answers a question nobody asked. +func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) { + p := BuildProfile(nil, behaviorNow()) + got := p.FormatWeekdayRU(time.Wednesday) + if strings.Contains(got, "у меня") { + t.Errorf("got %q", got) + } + if !strings.Contains(got, "у тебя") { + t.Errorf("got %q, want an answer about him", got) + } +} + +// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix +// it silently swallowed any future self-fact key starting with those letters. +func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want the key counted", p.All) + } + p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now) + if len(p.All) != 0 { + t.Fatalf("got %+v, want maven's own tuning state dropped", p.All) + } +} + +func TestPluralDaysRU(t *testing.T) { + for _, c := range []struct { + n int + want string + }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { + if got := pluralDaysRU(c.n); got != c.want { + t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want) + } + } +} From c21d8fdcee8c36f1586ab5f411f828cd7ee8ce6d Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:06:05 +0400 Subject: [PATCH 4/4] router: let a habit question outrank the day plan, and know the weekend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsDayPlanQuery fires on the token "планы" and its other-day list does not know weekday names, so "какие у меня обычно планы по вторникам?" was claimed by the day plan, which answered today's calendar stamped with today's date. The habit source never ran. The matcher now declines any utterance ParseHabitQuery claims, which keeps the decision out of the source table's ordering. Two gaps in the same matcher. Sunday had only its dative plural listed, so "в воскресенье" found no weekday. "по выходным" named days that no weekday word matches, so it was answered with the whole-week profile. Both are recognised now, and the weekend is read back as two days rather than pooled. Found in review of #59. --- cmd/mavend/actions_query.go | 3 +++ cmd/mavend/dayplan_test.go | 17 ++++++++++++ internal/memory/behavior.go | 24 +++++++++++++++++ internal/memory/behavior_test.go | 17 ++++++++++++ internal/router/calendar.go | 9 +++++++ internal/router/habit.go | 15 +++++++++++ internal/router/habit_test.go | 45 ++++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 30d94be..4b61170 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -214,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 e909c86..308c362 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -250,3 +250,20 @@ func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) { 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/internal/memory/behavior.go b/internal/memory/behavior.go index 692b2dc..8ed3260 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -346,6 +346,30 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) } +// FormatWeekendRU reads back what distinguishes Saturday and Sunday. +// +// The two days are answered separately rather than pooled: "по выходным" is a +// question about both, and a habit he has on Saturdays only is the interesting +// half of the answer, not noise to average away. +func (p Profile) FormatWeekendRU() string { + sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday] + switch { + case len(sat) > 0 && len(sun) > 0: + return 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. // diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index e70fe0d..e2ee61c 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -350,3 +350,20 @@ func TestPluralDaysRU(t *testing.T) { } } } + +// "по выходным" 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) + } + } +}