diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index 0fe4a84..6ceff05 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -17,6 +17,7 @@ import ( "time" "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/say" ) @@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool { // date for a day she did not understand. const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." -// dayWords — day references the calendar parser cannot resolve. A weekday name -// or a "через …" phrase means he asked about a specific other day. -var dayWords = []string{ - "понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен", - "через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", -} - // mentionsUnknownDay reports whether the question names a day the calendar // parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to // pick an honest reply over a confidently wrong one. // // Only called after ParseCalendarDate has already failed, so "завтра" and the // other words it does know never reach here. +// +// The weekday half was a list of STEMS matched with strings.Contains until +// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern +// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern +// always is: "среди", "средство" and "средний" all contain "сред", so a question +// carrying any of them was answered with onlyNearDaysReply instead of the date. +// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads +// the lexicon and asks the dictionary about the case. func mentionsUnknownDay(u string) bool { - for _, w := range dayWords { - if strings.Contains(u, w) { + for _, tok := range quietTokens(u) { + if tok == "через" { + return true + } + if _, ok := router.WeekdayIndex(tok); ok { return true } } diff --git a/cmd/mavend/ruwords_test.go b/cmd/mavend/ruwords_test.go new file mode 100644 index 0000000..5b98fc5 --- /dev/null +++ b/cmd/mavend/ruwords_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The +// weekday half of this guard was a list of stems matched with strings.Contains, +// so "среди", "средство" and "средний" all read as Wednesday and the question +// was answered with onlyNearDaysReply instead of a date. +// +// The other half of the fix is coverage: a stem list stops at the forms whoever +// wrote it thought of, and "воскресеньях" was not one of them. +func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) { + for _, u := range []string{ + "какое число в понедельник", + "какое число в среду", + "какое число в среде", + "что там по воскресеньям", + "what is the date on friday", + "какое число через неделю", + } { + if !mentionsUnknownDay(u) { + t.Errorf("mentionsUnknownDay(%q) = false, want true", u) + } + } + for _, u := range []string{ + "какое число в среднем", + "сколько это в среднем", + "какое сегодня средство", + "какое число", + } { + if mentionsUnknownDay(u) { + t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u) + } + } +} diff --git a/cmd/mavend/weatherq.go b/cmd/mavend/weatherq.go index 04f67f9..b8254ca 100644 --- a/cmd/mavend/weatherq.go +++ b/cmd/mavend/weatherq.go @@ -7,6 +7,10 @@ package main import ( "regexp" "strings" + + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" + "github.com/kami/maven/internal/router" ) // isWeatherQuery returns true if the utterance is about weather. @@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool { // come through whole and "в 5 утра" does not. var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`) -// weatherNonPlaces — words that follow "в" in a weather question and are not -// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and -// "тепло в комнате" is the same question about the same room. -var weatherNonPlaces = map[string]bool{ +// weatherRooms — the rooms of the house, which are the only words in this +// guard that belong to it. "какая погода в доме" is the smart-home sensor, not +// Open-Meteo, and "тепло в комнате" is the same question about the same room. +// +// The rest of the guard used to be a third copy of three closed sets that +// already exist in the lexicon: the weekdays, the parts of the day, and the +// words that follow "в" without naming a place (V-581). Each copy was short in +// its own direction — "среду" but not "среде", "утром" but not "утра", "целом" +// but not "общем" — so the same question phrased one word differently reached +// the geocoder as a city. +var weatherRooms = map[string]bool{ "доме": true, "квартире": true, "комнате": true, "спальне": true, "гостиной": true, "кухне": true, "гараже": true, "офисе": true, - "выходные": true, "субботу": true, "воскресенье": true, "понедельник": true, - "вторник": true, "среду": true, "четверг": true, "пятницу": true, - "обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true, - "вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true, + "обед": true, "обеде": true, "выходные": true, "выходных": true, +} + +// isWeatherNonPlace reports whether the word after "в" names something other +// than a place he could ask the weather for. +func isWeatherNonPlace(word string) bool { + if weatherRooms[word] { + return true + } + if _, ok := router.WeekdayIndex(word); ok { + return true + } + for _, w := range lexicon.PartsOfDay() { + if word == w || morph.SameWord(word, w) { + return true + } + } + for _, w := range lexicon.NotPlaceAfterV() { + if word == w { + return true + } + } + return false } // extractWeatherLocation returns the place he named, or the configured default @@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string { } place := strings.TrimSpace(m[1]) first := strings.ToLower(strings.Fields(place)[0]) - if weatherNonPlaces[first] { + if isWeatherNonPlace(first) { return defaultLoc } return place diff --git a/cmd/mavend/weatherq_test.go b/cmd/mavend/weatherq_test.go index 8e24a2f..18c61f2 100644 --- a/cmd/mavend/weatherq_test.go +++ b/cmd/mavend/weatherq_test.go @@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) { // the house sensors and the day words answer elsewhere. {"тепло в комнате?", "Berlin", "Berlin"}, {"какая погода в выходные", "Berlin", "Berlin"}, + // The cases the three private copies of the lexicon were short by + // (V-581): a weekday in a case the old map did not list, a part of the + // day in one it did not list, and "в общем". + {"какая погода в среде", "Berlin", "Berlin"}, + {"какая погода в воскресеньях", "Berlin", "Berlin"}, + {"какая погода в понедельникам", "Berlin", "Berlin"}, + {"какая погода в общем", "Berlin", "Berlin"}, } for _, c := range cases { if got := extractWeatherLocation(c.utterance, c.def); got != c.want { diff --git a/internal/router/calendar.go b/internal/router/calendar.go index de182d0..058208a 100644 --- a/internal/router/calendar.go +++ b/internal/router/calendar.go @@ -35,16 +35,14 @@ var dayPlanWords = []string{ // answer today and stamp it with today's date, which is a wrong answer where // falling through is only a terse one. // -// The weekday names are here as a refusal, not as a feature. "какие планы на -// понедельник?" carries no other-day token in the сегодня family and does carry -// "планы", so the plan used to claim it and recite today. +// A weekday is a refusal too, and it is not in this list: IsDayPlanQuery asks +// WeekdayIndex, so every case of every name refuses rather than the nine forms +// that used to be written out here (V-581). "какие планы на понедельник?" +// carries no other-day token in the сегодня family and does carry "планы", so +// the plan used to claim it and recite today. var otherDayWords = []string{ "завтра", "послезавтра", "вчера", "позавчера", "tomorrow", "yesterday", - "понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница", - "субботу", "суббота", "воскресенье", - "понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья", - "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "неделю", "неделя", "недели", "неделе", "выходные", "выходных", "выходным", "месяц", "месяца", "месяце", @@ -69,6 +67,9 @@ func IsDayPlanQuery(text string) bool { } toks := planTokens(text) for _, t := range toks { + if _, ok := WeekdayIndex(t); ok { + return false + } for _, w := range otherDayWords { if t == w { return false diff --git a/internal/router/habit.go b/internal/router/habit.go index f9e25d4..8f58ab6 100644 --- a/internal/router/habit.go +++ b/internal/router/habit.go @@ -27,26 +27,10 @@ var habitMarkers = []string{ "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, - "воскресенья": 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, - "thursday": time.Thursday, "thursdays": time.Thursday, - "friday": time.Friday, "fridays": time.Friday, - "saturday": time.Saturday, "saturdays": time.Saturday, - "sunday": time.Sunday, "sundays": time.Sunday, -} +// The weekday a habit question names comes from WeekdayIndex, not from a map +// here. This file used to keep its own declension table, which had "воскресеньях" +// and no "средах" — a list of forms is finished by whoever last thought of one, +// and a dictionary is not (V-581). // weekendWords — the weekend as one unit. "что я обычно делаю по выходным?" // has a habit marker and names days, but no weekday name is in it, so it used @@ -77,7 +61,7 @@ func ParseHabitQuery(text string) (HabitQuery, bool) { return HabitQuery{}, false } for _, t := range toks { - if wd, ok := weekdayWords[t]; ok { + if wd, ok := WeekdayIndex(t); ok { return HabitQuery{Weekday: wd, HasWeekday: true}, true } if weekendWords[t] { diff --git a/internal/router/timementions.go b/internal/router/timementions.go index 017c746..b5dd9f3 100644 --- a/internal/router/timementions.go +++ b/internal/router/timementions.go @@ -3,6 +3,7 @@ package router import ( "strconv" "strings" + "time" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/morph" @@ -234,16 +235,34 @@ func isMonth(tok string) bool { return false } -// isWeekday reports whether the token is a day of the week in any case. The -// lexicon lists the nominative, and "в пятницу" is what a reminder says, so the -// match is by lemma — grammar is morph's job, not a second word list. -func isWeekday(tok string) bool { - for i := 0; i < 7; i++ { - if morph.SameWord(tok, lexicon.Weekday(i)) { - return true +// WeekdayIndex reports which day of the week a token names, in any case and in +// either language, or false when it names none. +// +// One matcher for the whole daemon (V-581). Four files used to keep a weekday +// list of their own and each one was short in a different direction: the habit +// map had "воскресеньях" but no "средах", the plan refusal had "среду" but not +// "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so +// "среди" and "средство" read as Wednesday. The lexicon lists the nominative, +// every Russian case lemmatises to it, and only English needs its forms written +// out — the vendored dictionary is Russian and leaves "mondays" alone. +func WeekdayIndex(tok string) (time.Weekday, bool) { + t := strings.ToLower(strings.TrimSpace(tok)) + if n, ok := lexicon.WeekdayEnglish(t); ok { + return time.Weekday(n), true + } + for i, name := range lexicon.Weekdays() { + if morph.SameWord(t, name) { + return time.Weekday(i), true } } - return false + return 0, false +} + +// isWeekday reports whether the token is a day of the week, when the caller +// does not need to know which one. +func isWeekday(tok string) bool { + _, ok := WeekdayIndex(tok) + return ok } // timeMarkers — the words that name a time on their own: the qualifiers that diff --git a/internal/router/weekday_test.go b/internal/router/weekday_test.go new file mode 100644 index 0000000..d881f1a --- /dev/null +++ b/internal/router/weekday_test.go @@ -0,0 +1,44 @@ +package router + +import ( + "testing" + "time" +) + +// TestWeekdayIndexReplacesFourLists — four files kept a weekday list of their +// own and each was short in a different direction (V-581). The forms below are +// the ones at least one of those lists missed, so they are the point of having +// one matcher: the lexicon names the day and the dictionary answers the case. +func TestWeekdayIndexReplacesFourLists(t *testing.T) { + for _, tc := range []struct { + word string + want time.Weekday + }{ + {"понедельник", time.Monday}, + {"понедельникам", time.Monday}, + {"понедельником", time.Monday}, + {"вторник", time.Tuesday}, + {"среда", time.Wednesday}, + {"среду", time.Wednesday}, + {"среде", time.Wednesday}, + {"средам", time.Wednesday}, + {"четверга", time.Thursday}, + {"пятницу", time.Friday}, + {"субботам", time.Saturday}, + {"воскресеньях", time.Sunday}, + {"Воскресенье", time.Sunday}, + {"monday", time.Monday}, + {"Fridays", time.Friday}, + } { + got, ok := WeekdayIndex(tc.word) + if !ok || got != tc.want { + t.Errorf("WeekdayIndex(%q) = %v, %v; want %v, true", tc.word, got, ok, tc.want) + } + } + // A stem match said yes to all of these. A word match says no. + for _, w := range []string{"среди", "средство", "средний", "среднем", "субботник", "", "через"} { + if _, ok := WeekdayIndex(w); ok { + t.Errorf("WeekdayIndex(%q) claimed a weekday", w) + } + } +}