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) + } + } +}