diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go new file mode 100644 index 0000000..cfc06d4 --- /dev/null +++ b/cmd/mavend/system_reply_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +// systemHandler — a handler with nothing but a fixed clock, which is all +// replySystem needs. +func systemHandler(now time.Time) *reactiveHandler { + return &reactiveHandler{now: func() time.Time { return now }} +} + +// TestReplySystemDateOffset — "какое число завтра" must answer tomorrow's +// date, not today's (Vikunja #388). +func TestReplySystemDateOffset(t *testing.T) { + // Thursday, 30 July 2026. + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"какое сегодня число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число завтра", "завтра пятница, 31 июля 2026 года"}, + {"какое число послезавтра", "послезавтра суббота, 1 августа 2026 года"}, + {"какое было число вчера", "вчера среда, 29 июля 2026 года"}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} + +// A day she cannot work out must not come back as today's date — that is the +// same silent wrong answer #388 was about, one step further out. +func TestReplySystemUnknownDayIsHonest(t *testing.T) { + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + for _, u := range []string{ + "какое число в пятницу", + "какое число через неделю", + "какое число в понедельник", + } { + got := h.replySystem(context.Background(), router.Decision{Utterance: u}) + if got != onlyNearDaysReply { + t.Errorf("replySystem(%q) = %q, want the honest reply", u, got) + } + } + // The days she does know must not be caught by the same guard. + if got := h.replySystem(context.Background(), router.Decision{Utterance: "какое число завтра"}); got == onlyNearDaysReply { + t.Error("завтра was treated as an unknown day") + } +} + +// TestReplySystemClockCity — the clock arm must not answer local time for a +// question about another city (Vikunja #388). She keeps one clock, so every +// named place gets the honest "local time only" answer. +func TestReplySystemClockCity(t *testing.T) { + now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"который час", "сейчас 12 часов ровно"}, + {"который час в киеве", onlyLocalTimeReply}, + {"сколько времени в москве", onlyLocalTimeReply}, + {"который час в лондоне", onlyLocalTimeReply}, + {"который час в бишкеке", onlyLocalTimeReply}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index f1e0355..f736b73 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -752,7 +752,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) } // Calendar questions: "что у меня сегодня?", "планы на завтра?" - if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { + // h.now(), not time.Now(): the handler's clock is the injected one, so + // this arm can be tested at a fixed time like the rest. + if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) @@ -936,6 +938,101 @@ var ruMonths = []string{ "июля", "августа", "сентября", "октября", "ноября", "декабря", } +// onlyLocalTimeReply — the honest answer when the user asks the time somewhere +// other than here. She only keeps one clock, and saying so is better than +// naming the wrong city's time. +// +// There used to be a city→time-zone table here. It was removed on purpose: the +// user only ever asks for local time, so the table was a second list of cities +// to keep in step with the weather one for no gain. +const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." + +// notPlaceAfterV — words that follow "в" without naming a place, so +// mentionsUnknownPlace does not mistake them for a city. +var notPlaceAfterV = map[string]bool{ + "данный": true, "данную": true, "этот": true, "эту": true, + "котором": true, "какое": true, "какой": true, "который": true, + "общем": true, "точности": true, "курсе": true, "сутках": true, + "часах": true, "минутах": true, "секундах": true, "неделе": true, +} + +// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase +// that looks like a place we do not know ("который час в киеве"). Used only to +// pick the honest "local time only" reply instead of answering local time as +// if it were the city's. +func mentionsUnknownPlace(u string) bool { + toks := strings.Fields(u) + for i := 0; i+1 < len(toks); i++ { + if toks[i] != "в" && toks[i] != "во" { + continue + } + next := strings.Trim(toks[i+1], ".,?!") + if next == "" || notPlaceAfterV[next] { + continue + } + // A number after "в" is a clock ("в 5 часов"), not a place. + if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil { + continue + } + return true + } + return false +} + +// onlyNearDaysReply — she can work out today, tomorrow, the day after and +// yesterday, and nothing further. Said out loud instead of answering today's +// 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. +func mentionsUnknownDay(u string) bool { + for _, w := range dayWords { + if strings.Contains(u, w) { + return true + } + } + return false +} + +// ruClock renders the clock part of the time reply: "15 часов 4 минуты". +func ruClock(t time.Time) string { + h, m := t.Hour(), t.Minute() + hourWord := ruPlural(h, "час", "часа", "часов") + if m == 0 { + return fmt.Sprintf("%d %s ровно", h, hourWord) + } + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) +} + +// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date +// reply opens the way a person would say it. +func dayPrefix(now, day time.Time) string { + base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch int(day.Sub(base).Hours() / 24) { + case -1: + return "вчера" + case 0: + return "сегодня" + case 1: + return "завтра" + case 2: + return "послезавтра" + } + return "это" +} + func ruPlural(n int, one, two, many string) string { n = n % 100 if n > 10 && n < 20 { @@ -1014,18 +1111,29 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) switch { case strings.Contains(u, "час") || strings.Contains(u, "врем"): - h := now.Hour() - m := now.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") - if m == 0 { - return fmt.Sprintf("сейчас %d %s ровно", h, hourWord) + // "который час в киеве" — she keeps one clock, so any named place gets + // the honest answer. Never local time dressed up as the city's. + if mentionsUnknownPlace(u) { + return onlyLocalTimeReply } - minWord := ruPlural(m, "минута", "минуты", "минут") - return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord) + return "сейчас " + ruClock(now) case strings.Contains(u, "день") || strings.Contains(u, "числ"): - dow := ruWeekdays[now.Weekday()] - month := ruMonths[now.Month()-1] - return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year()) + // "какое число завтра" — answer for the day the user asked about, + // not today. Reuses the router's calendar day-word parser. + day := now + prefix := "сегодня" + if d, ok := router.ParseCalendarDate(u, now); ok { + day = d + prefix = dayPrefix(now, d) + } else if mentionsUnknownDay(u) { + // He named a day she cannot work out ("в пятницу", "через неделю"). + // Answering today's date here would be the same silent wrong answer + // this arm was fixed for, so say what she can do instead. + return onlyNearDaysReply + } + dow := ruWeekdays[day.Weekday()] + month := ruMonths[day.Month()-1] + return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year()) case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"): return "присутствие пока не подключено к голосовому запросу." case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"): diff --git a/internal/router/slots.go b/internal/router/slots.go index ea579f5..95998a2 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) { return "", false } -// ParseCalendarDate detects RU calendar date words in text and returns the -// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). -// Returns zero time + false if no match. +// ParseCalendarDate detects RU/EN calendar day words in text and returns +// midnight of that day in now's own time zone. Handles "сегодня", "завтра", +// "послезавтра", "вчера" (and the English words). Returns zero time + false +// if no match. +// +// "послезавтра" is checked before "завтра" because it contains it. func ParseCalendarDate(text string, now time.Time) (time.Time, bool) { lower := strings.ToLower(text) - if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") { - return now.Truncate(24 * time.Hour), true - } - if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") { - return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true + switch { + case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"): + return midnight(now, 0), true + case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"): + return midnight(now, 2), true + case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"): + return midnight(now, 1), true + case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"): + return midnight(now, -1), true } return time.Time{}, false } + +// midnight returns the start of the day that is `days` away from now, in +// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead). +func midnight(now time.Time, days int) time.Time { + y, m, d := now.AddDate(0, 0, days).Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()) +} diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go index 4c52d37..f3e9f85 100644 --- a/internal/router/slots_ru_test.go +++ b/internal/router/slots_ru_test.go @@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) { {"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, {"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, {"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"какое число послезавтра", time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), true}, + {"что было вчера", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, + {"yesterday plans", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, {"какая погода", time.Time{}, false}, {"сколько времени", time.Time{}, false}, {"", time.Time{}, false},