Answer the day the user asked about and the city he named (#388)

replySystem had two arms that PR 30 made reachable, and both answered confidently wrong: the date arm keyword-matched "числ" and always answered today, so "какое число завтра" answered today; the clock arm ignored a named city and answered local time. The date arm now reads the day word through router.ParseCalendarDate (which grew послезавтра/вчера and now cuts the day boundary in the local zone instead of UTC). The clock arm answers the named zone when it resolves offline from the tz database embedded in the binary, and otherwise says plainly that she only knows local time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 14:02:57 +04:00
parent b6f47fbeb6
commit d00929ac0b
4 changed files with 219 additions and 18 deletions
+58
View File
@@ -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)
}
}
}
+136 -10
View File
@@ -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, "интернет"):
+22 -8
View File
@@ -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())
}
+3
View File
@@ -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},