Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dbf67f8f9 | |||
| 84ba217892 | |||
| f179ae2fde | |||
| d00929ac0b |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-11
@@ -752,7 +752,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar questions: "что у меня сегодня?", "планы на завтра?"
|
// 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))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
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 {
|
func ruPlural(n int, one, two, many string) string {
|
||||||
n = n % 100
|
n = n % 100
|
||||||
if n > 10 && n < 20 {
|
if n > 10 && n < 20 {
|
||||||
@@ -1014,18 +1111,29 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
||||||
h := now.Hour()
|
// "который час в киеве" — she keeps one clock, so any named place gets
|
||||||
m := now.Minute()
|
// the honest answer. Never local time dressed up as the city's.
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
if mentionsUnknownPlace(u) {
|
||||||
if m == 0 {
|
return onlyLocalTimeReply
|
||||||
return fmt.Sprintf("сейчас %d %s ровно", h, hourWord)
|
|
||||||
}
|
}
|
||||||
minWord := ruPlural(m, "минута", "минуты", "минут")
|
return "сейчас " + ruClock(now)
|
||||||
return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord)
|
|
||||||
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
||||||
dow := ruWeekdays[now.Weekday()]
|
// "какое число завтра" — answer for the day the user asked about,
|
||||||
month := ruMonths[now.Month()-1]
|
// not today. Reuses the router's calendar day-word parser.
|
||||||
return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year())
|
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, "человек дома"):
|
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||||
return "присутствие пока не подключено к голосовому запросу."
|
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, "интернет"):
|
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, "интернет"):
|
||||||
|
|||||||
@@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseCalendarDate detects RU calendar date words in text and returns the
|
// ParseCalendarDate detects RU/EN calendar day words in text and returns
|
||||||
// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow").
|
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
|
||||||
// Returns zero time + false if no match.
|
// "послезавтра", "вчера" (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) {
|
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") {
|
switch {
|
||||||
return now.Truncate(24 * time.Hour), true
|
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
|
||||||
}
|
return midnight(now, 0), true
|
||||||
if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") {
|
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
|
||||||
return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true
|
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
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) {
|
|||||||
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
{"расписание на завтра", 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},
|
{"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},
|
{"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},
|
{"сколько времени", time.Time{}, false},
|
||||||
{"", time.Time{}, false},
|
{"", time.Time{}, false},
|
||||||
|
|||||||
Reference in New Issue
Block a user