diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go new file mode 100644 index 0000000..1e8732e --- /dev/null +++ b/cmd/mavend/system_reply_test.go @@ -0,0 +1,58 @@ +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) + } + } +} + +// TestReplySystemClockCity — the clock arm must not answer local time for a +// question about another city (Vikunja #388). Known cities get their own zone; +// unknown places get an honest "local time only". +func TestReplySystemClockCity(t *testing.T) { + // 12:00 UTC — Kyiv is +03 in July, Moscow +03, London +01. + now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"который час", "сейчас 12 часов ровно"}, + {"который час в киеве", "в Киеве сейчас 15 часов ровно"}, + {"сколько времени в москве", "в Москве сейчас 15 часов ровно"}, + {"который час в лондоне", "в Лондоне сейчас 13 часов ровно"}, + {"который час в бишкеке", 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..249fba6 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -55,6 +55,9 @@ import ( "strings" "sync" "time" + // Embeds the tz database in the binary so time.LoadLocation works even in + // a container image without /usr/share/zoneinfo. Stdlib, offline. + _ "time/tzdata" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/audio" @@ -936,6 +939,115 @@ var ruMonths = []string{ "июля", "августа", "сентября", "октября", "ноября", "декабря", } +// onlyLocalTimeReply — the honest answer when the user names a place whose +// time zone we cannot resolve offline. Better than confidently naming the +// wrong city's time. +const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." + +// cityZone — a city we can answer the clock for: its IANA time zone (resolved +// from the tzdata built into the binary, never over the network) and its +// Russian name in the "в ..." case. +type cityZone struct { + zone string + prepositional string +} + +// cityZones maps a lowercase city stem to its zone. Stems, not full words, so +// "в москве" / "москва" both hit. Keep in sync-ish with the weather city list. +var cityZones = map[string]cityZone{ + "москв": {"Europe/Moscow", "Москве"}, + "moscow": {"Europe/Moscow", "Москве"}, + "питер": {"Europe/Moscow", "Питере"}, + "петербур": {"Europe/Moscow", "Петербурге"}, + "киев": {"Europe/Kyiv", "Киеве"}, + "kyiv": {"Europe/Kyiv", "Киеве"}, + "kiev": {"Europe/Kyiv", "Киеве"}, + "минск": {"Europe/Minsk", "Минске"}, + "лондон": {"Europe/London", "Лондоне"}, + "london": {"Europe/London", "Лондоне"}, + "париж": {"Europe/Paris", "Париже"}, + "paris": {"Europe/Paris", "Париже"}, + "берлин": {"Europe/Berlin", "Берлине"}, + "berlin": {"Europe/Berlin", "Берлине"}, + "нью-йорк": {"America/New_York", "Нью-Йорке"}, + "new york": {"America/New_York", "Нью-Йорке"}, + "токио": {"Asia/Tokyo", "Токио"}, + "tokyo": {"Asia/Tokyo", "Токио"}, + "тбилиси": {"Asia/Tbilisi", "Тбилиси"}, + "екатеринбург": {"Asia/Yekaterinburg", "Екатеринбурге"}, + "новосибирск": {"Asia/Novosibirsk", "Новосибирске"}, + "владивосток": {"Asia/Vladivostok", "Владивостоке"}, +} + +// lookupCityZone finds a known city named in the utterance. +func lookupCityZone(u string) (cityZone, bool) { + for stem, cz := range cityZones { + if strings.Contains(u, stem) { + return cz, true + } + } + return cityZone{}, false +} + +// 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 +} + +// 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 +1126,32 @@ 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) + // "который час в киеве" — answer for the named city when we know its + // time zone locally, never guess. Unknown place: say so plainly. + if city, ok := lookupCityZone(u); ok { + loc, err := time.LoadLocation(city.zone) + if err != nil { + log.Printf("voice: load zone %s: %v", city.zone, err) + return onlyLocalTimeReply + } + return fmt.Sprintf("в %s сейчас %s", city.prepositional, ruClock(now.In(loc))) } - minWord := ruPlural(m, "минута", "минуты", "минут") - return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord) + if mentionsUnknownPlace(u) { + return onlyLocalTimeReply + } + 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) + } + 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},