diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e855f33..df1f5d5 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -51,6 +51,11 @@ var querySources = []querySource{ // the more specific ask (its matcher requires a plan word), so the calendar // listing would otherwise swallow it. {"day-plan", (*reactiveHandler).queryDayPlan}, + // Also before "calendar": "что я обычно делаю по средам?" names a weekday, + // and the habit question is the more specific one. Its matcher requires a + // habit marker ("обычно", "каждый", …), so a question about this coming + // Wednesday still reaches the calendar. + {"habits", (*reactiveHandler).queryHabits}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, @@ -135,6 +140,35 @@ func isRestOfDayQuery(text string) bool { return strings.Contains(s, "дальше") || strings.Contains(s, "next") } +// habitFactWindow — how many recent 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. +const habitFactWindow = 2000 + +// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the +// answer out of the fact log rather than asking the model to summarise a life: +// see internal/memory/behavior.go for why nothing here is generated. +func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) { + q, ok := router.ParseHabitQuery(t.dec.Utterance) + if !ok { + return "", false + } + facts, err := h.api.RecentFacts(ctx, habitFactWindow) + if err != nil { + log.Printf("voice: habits: recent facts: %v", err) + return "не получилось посмотреть записи.", true + } + obs := make([]memory.Observation, 0, len(facts)) + for _, f := range facts { + obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind}) + } + profile := memory.BuildProfile(obs, h.now()) + if q.HasWeekday { + return profile.FormatWeekdayRU(q.Weekday), true + } + return profile.FormatOverallRU(), true +} + // queryCalendar — "что у меня сегодня?", "планы на завтра?" // h.now(), not time.Now(): the handler's clock is the injected one, so this // source can be tested at a fixed time like the rest. diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index d225cb2..0bc40b8 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -141,3 +141,87 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) { t.Errorf("day-plan at %d must come before calendar at %d", plan, cal) } } + +// habitAPI answers only RecentFacts — the whole input the behaviour profile +// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired. +type habitAPI struct { + ipc.UnimplementedCoreAPI + facts []ipc.Fact + err error + calls int +} + +func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { + a.calls++ + return a.facts, a.err +} + +// tuesdayFacts — n weekly Tuesday rows for key, ending before now. +func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact { + d := now + for d.Weekday() != time.Tuesday { + d = d.AddDate(0, 0, -1) + } + var out []ipc.Fact + for i := 0; i < weeks; i++ { + day := d.AddDate(0, 0, -7*i) + out = append(out, ipc.Fact{ + Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()), + Kind: "self", + Key: key, + }) + } + return out +} + +func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) { + now := planDay() // a Monday + api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)} + h := &reactiveHandler{api: api, now: func() time.Time { return now }} + + reply, ok := h.queryHabits(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"}, + }) + if !ok { + t.Fatal("the habit source must claim a habit question") + } + if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want { + t.Errorf("reply = %q, want %q", reply, want) + } +} + +func TestQueryHabitsPassesOnEverythingElse(t *testing.T) { + now := planDay() + for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} { + api := &habitAPI{} + h := &reactiveHandler{api: api, now: func() time.Time { return now }} + if reply, ok := h.queryHabits(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: q}, + }); ok { + t.Errorf("%q was claimed by the habit source (reply %q)", q, reply) + } + if api.calls != 0 { + t.Errorf("%q scanned the fact log for a profile it does not want", q) + } + } +} + +// Both specific sources must precede the calendar listing, which matches any +// utterance naming a day. +func TestHabitSourcePrecedesCalendar(t *testing.T) { + habits, cal := -1, -1 + for i, s := range querySources { + switch s.name { + case "habits": + habits = i + case "calendar": + cal = i + } + } + if habits < 0 || cal < 0 { + t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal) + } + if habits > cal { + t.Errorf("habits at %d must come before calendar at %d", habits, cal) + } +} diff --git a/docs/plans/09-behavioral-memory.md b/docs/plans/09-behavioral-memory.md index 9313303..363c568 100644 --- a/docs/plans/09-behavioral-memory.md +++ b/docs/plans/09-behavioral-memory.md @@ -27,3 +27,36 @@ 6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer 7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash` 8. Test with synthetic fact history — verify weekly schedule is correctly inferred + +--- + +## Status (2026-08-01) — partially shipped, deliberately narrowed + +Shipped on `overnight/behavior-profile`: + +- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of + self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and + `FormatWeekdayRU` / `FormatOverallRU` for the spoken answer. +- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker + ("обычно", "каждую", "привычки", …) and parses the weekday deterministically. +- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю + по вторникам?" is answered. + +**Not shipped, and not to be shipped as written:** + +- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not + generated. A 1.7B asked to summarise a year of habits produces fluent claims + about the owner's life that no row supports, and a wrong claim about him is the + most expensive kind of wrong maven can be. Counting is verifiable and cheap. +- *Step 5, incremental updates on fact write.* There is no cache to keep fresh — + the profile is recomputed on the question, so a new fact is already in the next + answer. A cached profile that can disagree with its own rows is two truths. +- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag, + and a nudge at 08:00 every day proposing the day is the definition of one. The + sanctioned path from "she noticed a pattern" to "she acts on it" already exists: + `internal/pattern/detector.go` proposes a routine, and the owner accepts it on + `/routines`. It goes through him. + +Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7). +The counted profile needs no new IPC method to be *asked* about — the query source +reads `RecentFacts` over the existing surface — so this is a display concern only. diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go new file mode 100644 index 0000000..2066b57 --- /dev/null +++ b/internal/memory/behavior.go @@ -0,0 +1,254 @@ +package memory + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Behavioural memory — "what do I usually do?" (Vikunja #254). +// +// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md +// asks for an LLM to write a behaviour profile daily and store it as a fact; +// this does not do that, on purpose. A 1.7B asked to summarise a year of habits +// will produce fluent claims about the owner's life that no row in the store +// supports, and a wrong claim about him is the most expensive kind of wrong +// maven can be. Counting distinct days per weekday is verifiable, cheap enough +// to run on the question, and cannot invent a habit he does not have. +// +// Recomputed on read rather than cached as a fact for the same reason the store +// is append-only: a cached profile can disagree with the rows it came from, and +// then there are two truths. The plan's step 5 ("profile updates on fact write") +// exists to keep a cache fresh; there is no cache, so a new fact is already in +// the next answer. +// +// It is also read-only and unprompted-free. The plan's step 4 — a morning +// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a +// nag, and proposing plans at 08:00 every day is the definition of one. Pattern +// inference that leads to a routine the owner accepts already exists in +// internal/pattern with the proposal queue on /routines; that is the sanctioned +// path from "she noticed" to "she acts", and it goes through him. + +// Observation — one thing the owner was recorded doing, reduced to what a habit +// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them +// so this package stays free of both. +type Observation struct { + At time.Time + Key string + Kind string // "self" | "env" | "config" +} + +// Activity — one recurring thing, as counted. Days is the number of DISTINCT +// days it was observed on, which is the number that decides whether something +// is a habit; Count can be inflated by one busy day. +// +// 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. +type Activity struct { + Key string + Days int + Count int + TypicalAt time.Duration +} + +// Profile — the counted behaviour model. Weekly holds the activities that +// recur on a given weekday, Overall the ones that recur at all. +type Profile struct { + Since time.Time + Until time.Time + Weekly map[time.Weekday][]Activity + All []Activity +} + +// MinHabitDays — how many distinct days an activity must appear on before maven +// will call it usual. Two is the smallest number that can distinguish a habit +// from a one-off; below that she says she does not know yet, which is true. +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. +var nonBehaviouralKeyPrefixes = []string{ + "calendar_event_", + "cooldown:", + "quiet", + "behavior_profile", +} + +// 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. +// +// Only self-facts count. An env row is the world (weather, a relayed meeting), +// and a config row is maven's own state; neither says anything about what he +// usually does. +func BuildProfile(obs []Observation, now time.Time) Profile { + loc := now.Location() + p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}} + + type bucket struct { + days map[string]struct{} + count int + mins []int + } + // key → bucket, and (weekday, key) → bucket. + all := map[string]*bucket{} + weekly := map[time.Weekday]map[string]*bucket{} + + for _, o := range obs { + if o.Kind != "self" { + continue + } + key := strings.TrimSpace(o.Key) + if key == "" || nonBehavioural(key) { + continue + } + at := o.At.In(loc) + if at.IsZero() || at.After(now) { + continue + } + if p.Since.IsZero() || at.Before(p.Since) { + p.Since = at + } + day := at.Format("2006-01-02") + minute := at.Hour()*60 + at.Minute() + + bump := func(m map[string]*bucket) { + b := m[key] + if b == nil { + b = &bucket{days: map[string]struct{}{}} + m[key] = b + } + b.days[day] = struct{}{} + b.count++ + b.mins = append(b.mins, minute) + } + bump(all) + wd := at.Weekday() + if weekly[wd] == nil { + weekly[wd] = map[string]*bucket{} + } + bump(weekly[wd]) + } + + harvest := func(m map[string]*bucket) []Activity { + var out []Activity + for key, b := range m { + if len(b.days) < MinHabitDays { + continue + } + out = append(out, Activity{ + Key: key, + Days: len(b.days), + Count: b.count, + TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute, + }) + } + // Most-established first, then earliest in the day, then by key so the + // same history always reads back the same way. + sort.Slice(out, func(i, j int) bool { + if out[i].Days != out[j].Days { + return out[i].Days > out[j].Days + } + if out[i].TypicalAt != out[j].TypicalAt { + return out[i].TypicalAt < out[j].TypicalAt + } + return out[i].Key < out[j].Key + }) + return out + } + + p.All = harvest(all) + for wd, m := range weekly { + if acts := harvest(m); len(acts) > 0 { + p.Weekly[wd] = acts + } + } + return p +} + +func nonBehavioural(key string) bool { + for _, p := range nonBehaviouralKeyPrefixes { + if strings.HasPrefix(key, p) { + return true + } + } + return false +} + +// medianInt — the middle value, averaging the two middles on an even count. +func medianInt(xs []int) int { + if len(xs) == 0 { + return 0 + } + s := make([]int, len(xs)) + copy(s, xs) + sort.Ints(s) + mid := len(s) / 2 + if len(s)%2 == 1 { + return s[mid] + } + return (s[mid-1] + s[mid]) / 2 +} + +// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read +// back. Index is time.Weekday. +var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"} + +// activityRU glosses the loop's known fact keys. An unknown key is read back +// verbatim: it is what the store holds, and inventing a Russian phrase for a key +// maven does not recognise would be putting words in his mouth. +var activityRU = map[string]string{ + "water": "пьёшь воду", + "meal": "ешь", + "sleep": "спишь", + "break": "делаешь перерыв", + "shower": "принимаешь душ", + "walk": "гуляешь", + "pills": "пьёшь витамины", + "workout": "тренируешься", +} + +// FormatWeekdayRU reads back what he usually does on a given weekday. +// Second person singular and informal, as she speaks TO him. +func (p Profile) FormatWeekdayRU(wd time.Weekday) string { + acts := p.Weekly[wd] + day := weekdayRU[int(wd)%7] + if len(acts) == 0 { + return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) + } + return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) +} + +// FormatOverallRU reads back the habits that hold across the whole week. +func (p Profile) FormatOverallRU() string { + if len(p.All) == 0 { + return "я ещё не набрала достаточно записей, чтобы говорить о привычках." + } + return fmt.Sprintf("обычно ты %s.", joinActivities(p.All)) +} + +// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is +// not an answer; the most established few are. +const maxRecited = 5 + +func joinActivities(acts []Activity) string { + if len(acts) > maxRecited { + acts = acts[:maxRecited] + } + parts := make([]string, len(acts)) + for i, a := range acts { + gloss, ok := activityRU[a.Key] + if !ok { + gloss = a.Key + } + parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, + int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) + } + if len(parts) == 1 { + return parts[0] + } + return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1] +} diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go new file mode 100644 index 0000000..ad2150b --- /dev/null +++ b/internal/memory/behavior_test.go @@ -0,0 +1,163 @@ +package memory + +import ( + "strings" + "testing" + "time" + "unicode" +) + +// habitHistory — n weeks of the same weekday, at the given local time. +func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation { + var out []Observation + d := from + for d.Weekday() != wd { + d = d.AddDate(0, 0, -1) + } + for i := 0; i < weeks; i++ { + day := d.AddDate(0, 0, -7*i) + out = append(out, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()), + Key: key, + Kind: "self", + }) + } + return out +} + +func behaviorNow() time.Time { + // A Monday, so "по вторникам" is a past weekday and not today. + return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC) +} + +func TestBuildProfileCountsWeekdayHabits(t *testing.T) { + now := behaviorNow() + obs := append( + habitHistory("workout", time.Tuesday, 19, 0, 4, now), + habitHistory("water", time.Tuesday, 9, 0, 3, now)..., + ) + p := BuildProfile(obs, now) + + tue := p.Weekly[time.Tuesday] + if len(tue) != 2 { + t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue) + } + // Most-established first. + if tue[0].Key != "workout" || tue[0].Days != 4 { + t.Errorf("first = %+v, want workout on 4 days", tue[0]) + } + if tue[0].TypicalAt != 19*time.Hour { + t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt) + } + if len(p.Weekly[time.Wednesday]) != 0 { + t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday]) + } + if len(p.All) != 2 { + t.Errorf("the week-wide list should hold both: %+v", p.All) + } +} + +// A one-off is not a habit. Saying "ты обычно X" off a single row is a +// confidently wrong claim about his life. +func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) { + now := behaviorNow() + obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now) + // Three rows, same day — a busy Tuesday, not a habit. + obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"}) + obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"}) + + p := BuildProfile(obs, now) + if len(p.All) != 0 || len(p.Weekly) != 0 { + t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly) + } + if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") { + t.Errorf("empty profile reads %q", got) + } +} + +// Only self-facts describe him. Env rows are the world and config rows are +// maven's own tuning state; counting either as a habit would be a category +// error the owner would then be told about. +func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) { + now := behaviorNow() + var obs []Observation + for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) { + o.Kind = "env" + obs = append(obs, o) + } + for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) { + obs = append(obs, o) // kind=self, but a machinery key + } + for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) { + obs = append(obs, o) + } + if p := BuildProfile(obs, now); len(p.All) != 0 { + t.Fatalf("nothing here is a habit of his: %+v", p.All) + } +} + +// The median, not the mean: one 03:00 outlier must not move a morning habit +// into the night. +func TestBuildProfileTypicalTimeIsMedian(t *testing.T) { + now := behaviorNow() + obs := habitHistory("water", time.Tuesday, 9, 0, 4, now) + obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"}) + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v", p.All) + } + if p.All[0].TypicalAt != 9*time.Hour { + t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt) + } +} + +func TestProfileFormatRUPersona(t *testing.T) { + now := behaviorNow() + obs := append( + habitHistory("workout", time.Tuesday, 19, 0, 4, now), + habitHistory("water", time.Tuesday, 9, 5, 3, now)..., + ) + p := BuildProfile(obs, now) + + got := p.FormatWeekdayRU(time.Tuesday) + want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05." + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } + if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") { + t.Errorf("an unknown weekday reads %q", empty) + } + // Persona: she addresses him informally, never in the masculine about + // herself, and never with a pet name. + for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} { + // Whole words: "ничего" contains "его", and a substring test would + // call a correct sentence a persona violation. + for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) + }) { + switch tok { + case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его": + t.Errorf("%q uses %q", s, tok) + } + } + } +} + +// An unrecognised key is read back verbatim rather than glossed into something +// maven made up. +func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now) + if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") { + t.Errorf("got %q", got) + } +} + +// A future-dated row is a clock problem, not a habit. +func TestBuildProfileIgnoresFutureRows(t *testing.T) { + now := behaviorNow() + obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0)) + if p := BuildProfile(obs, now); len(p.All) != 0 { + t.Fatalf("future rows counted: %+v", p.All) + } +} diff --git a/internal/router/habit.go b/internal/router/habit.go new file mode 100644 index 0000000..92add2c --- /dev/null +++ b/internal/router/habit.go @@ -0,0 +1,73 @@ +package router + +import "time" + +// Habit queries — "что я обычно делаю по вторникам?" (Vikunja #254). +// +// Deterministic matching, like the calendar and plan matchers: the LLM router +// classifies the intent, but WHICH weekday was asked about is a lookup, not a +// generation. A model that answers "по вторникам" for a question about Thursday +// gives a confidently wrong account of the owner's own life. + +// 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. +type HabitQuery struct { + Weekday time.Weekday + HasWeekday bool +} + +// habitMarkers — the words that make a question about habit rather than about +// today. Without one of these, "что я делаю" is a question about right now, and +// the recall path owns it. +var habitMarkers = []string{ + "обычно", "обычное", "чаще", "постоянно", "привычки", "привычка", "привычках", + "регулярно", "каждый", "каждую", "каждое", "usually", "habits", "habit", + "typically", "normally", +} + +// weekdayWords — every form of a weekday name maven needs to recognise, +// including the "по …ам" plural the question is usually phrased in. +var weekdayWords = map[string]time.Weekday{ + "понедельник": time.Monday, "понедельникам": time.Monday, + "вторник": time.Tuesday, "вторникам": time.Tuesday, + "среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday, + "четверг": time.Thursday, "четвергам": time.Thursday, + "пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday, + "суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday, + "воскресенье": time.Sunday, "воскресеньям": time.Sunday, + "monday": time.Monday, "mondays": time.Monday, + "tuesday": time.Tuesday, "tuesdays": time.Tuesday, + "wednesday": time.Wednesday, "wednesdays": time.Wednesday, + "thursday": time.Thursday, "thursdays": time.Thursday, + "friday": time.Friday, "fridays": time.Friday, + "saturday": time.Saturday, "saturdays": time.Saturday, + "sunday": time.Sunday, "sundays": time.Sunday, +} + +// ParseHabitQuery reports whether an utterance asks what the owner usually +// does, and on which weekday if it names one. +// +// A habit marker is required. "что я делаю в среду?" without one is a question +// about this coming Wednesday — the calendar's job — and answering it with a +// statistical average would be answering a different question. +func ParseHabitQuery(text string) (HabitQuery, bool) { + toks := planTokens(text) + marked := false + for _, t := range toks { + for _, m := range habitMarkers { + if t == m { + marked = true + break + } + } + } + if !marked { + return HabitQuery{}, false + } + for _, t := range toks { + if wd, ok := weekdayWords[t]; ok { + return HabitQuery{Weekday: wd, HasWeekday: true}, true + } + } + return HabitQuery{}, true +} diff --git a/internal/router/habit_test.go b/internal/router/habit_test.go new file mode 100644 index 0000000..8f868aa --- /dev/null +++ b/internal/router/habit_test.go @@ -0,0 +1,45 @@ +package router + +import ( + "testing" + "time" +) + +func TestParseHabitQuery(t *testing.T) { + tests := []struct { + in string + ok bool + wd time.Weekday + hasWD bool + }{ + {"что я обычно делаю по вторникам?", true, time.Tuesday, true}, + {"что я обычно делаю?", true, 0, false}, + {"какие у меня привычки", true, 0, false}, + {"что я каждую пятницу делаю", true, time.Friday, true}, + {"what do i usually do on mondays?", true, time.Monday, true}, + // No habit marker: this is a question about the coming Wednesday, and + // the calendar owns it. Answering with an average answers the wrong + // question. + {"что я делаю в среду?", false, 0, false}, + {"что у меня сегодня?", false, 0, false}, + {"какие планы на сегодня?", false, 0, false}, + {"", false, 0, false}, + } + for _, tt := range tests { + q, ok := ParseHabitQuery(tt.in) + if ok != tt.ok { + t.Errorf("ParseHabitQuery(%q) ok = %v, want %v", tt.in, ok, tt.ok) + continue + } + if !ok { + continue + } + if q.HasWeekday != tt.hasWD { + t.Errorf("ParseHabitQuery(%q) hasWeekday = %v, want %v", tt.in, q.HasWeekday, tt.hasWD) + continue + } + if q.HasWeekday && q.Weekday != tt.wd { + t.Errorf("ParseHabitQuery(%q) weekday = %v, want %v", tt.in, q.Weekday, tt.wd) + } + } +}