router: let a habit question outrank the day plan, and know the weekend

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.
This commit is contained in:
kami
2026-08-01 14:06:05 +04:00
parent ba33a677f8
commit c21d8fdcee
7 changed files with 130 additions and 0 deletions
+3
View File
@@ -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
}
+17
View File
@@ -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)
}
}
+24
View File
@@ -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.
//
+17
View File
@@ -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)
}
}
+9
View File
@@ -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 {
+15
View File
@@ -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
}
+45
View File
@@ -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)
}
}
}