Tonight's work as one branch: all 46 reviewed PRs, verified green #47

Merged
kami merged 135 commits from integration/jul31 into master 2026-07-31 19:41:52 +02:00
2 changed files with 14 additions and 67 deletions
Showing only changes of commit 3dbf67f8f9 - Show all commits
+5 -6
View File
@@ -57,17 +57,16 @@ func TestReplySystemUnknownDayIsHonest(t *testing.T) {
}
// 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".
// 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) {
// 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},
{"сколько времени в москве", onlyLocalTimeReply},
{"который час в лондоне", onlyLocalTimeReply},
{"который час в бишкеке", onlyLocalTimeReply},
}
for _, c := range cases {
+9 -61
View File
@@ -55,9 +55,6 @@ 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"
@@ -941,56 +938,15 @@ 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.
// 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 = "я знаю только местное время, про другие города пока не скажу."
// 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{
@@ -1155,16 +1111,8 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
switch {
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
// "который час в киеве" — 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)))
}
// "который час в киеве" — 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
}