// Package main — ruwords.go holds Russian language + calendar/time formatting // helpers used by the voice reply paths (replySystem, the reminder/routine // phrasing, etc). Pure functions, no receivers: weekday/month name tables, // plural agreement, clock/date rendering, and the "do I actually know this // place/day" guards that pick an honest reply over a confidently wrong one. // Extend this file rather than voice.go for anything in that shape. // // Count agreement is not here. It is say.CountWord, because there were four // copies of the same three-way rule and two of the sites that needed it were // spelling one form out (Vikunja #521). package main import ( "fmt" "strconv" "strings" "time" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/say" ) // Weekday and month names are a closed class — the language has seven and // twelve — so they live complete in internal/lexicon, where internal/ttsnorm // reads the same twelve month names instead of keeping a second copy // (Vikunja #525). // 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. Closed set, kept // complete in internal/lexicon. var notPlaceAfterV = func() map[string]bool { m := map[string]bool{} for _, w := range lexicon.NotPlaceAfterV() { m[w] = true } return m }() // 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 = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." // 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. // // The weekday half was a list of STEMS matched with strings.Contains until // V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern // the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern // always is: "среди", "средство" and "средний" all contain "сред", so a question // carrying any of them was answered with onlyNearDaysReply instead of the date. // Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads // the lexicon and asks the dictionary about the case. func mentionsUnknownDay(u string) bool { for _, tok := range quietTokens(u) { if tok == "через" { return true } if _, ok := router.WeekdayIndex(tok); ok { 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 := say.CountWord(h, "час", "часа", "часов") if m == 0 { return fmt.Sprintf("%d %s ровно", h, hourWord) } return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(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 "это" } // hasDurationWords checks whether u is asking about elapsed/remaining time // rather than the current clock — guards replySystem from replying "сейчас // X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. func hasDurationWords(u string) bool { s := strings.ToLower(strings.TrimSpace(u)) // First-word duration markers (same keywords as timeQueryBuild in stage0). first := strings.Fields(s) if len(first) > 0 { switch first[0] { case "прошло", "осталось", "пройдет", "минуло", "проходит": return true } } // Broader duration keywords appearing anywhere in the utterance. if strings.Contains(s, "прошло") || strings.Contains(s, "осталось") { return true } if strings.Contains(s, " до ") { return true } return false } // formatTime returns a human-readable Russian time string for a fact timestamp. // Used by the query handler when answering "когда я это сделал?"-style questions. func formatTime(t time.Time) string { now := time.Now() // The argument is a fact's Ts, which the store hands back as UTC. Only the // last branch names a wall clock, and it named the store's until V-614: an // answer to "когда я это сделал?" read hours off, in the same sentence // shape the plan reads a day in. t = t.Local() if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) { return "только что" } diff := now.Sub(t) switch { case diff < 10*time.Minute: return "несколько минут назад" case diff < 60*time.Minute: n := int(diff.Minutes()) return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут")) case diff < 2*time.Hour: return "час назад" case diff < 24*time.Hour: n := int(diff.Hours()) return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов")) default: // Not t.Format("2 января …"): Go reads that as a literal, so every // fact older than a day used to read as January (Vikunja #507). return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04")) } }