46acf3cba0
FormatRU printed a plan item's At raw. An event and a reminder come off the store as UTC — a calendar fact's Ts, a reminder's FireTs — while a checklist line is built in the asking clock's zone, so one spoken sentence named two zones. This is the voice path, so it is what he actually heard; the same defect on /morning and /events was V-612. Every hour is now read in the plan's own zone, Date's, which BuildPlan sets from the asking clock. The rest-of-day path in queryDayPlan rebuilds a plan off the wire, where nothing had put the instants in that frame, so it does now. formatTime is the same bug in the same daemon: "когда я это сделал?" names a fact's Ts, and the branch that prints a wall clock printed the store's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
7.1 KiB
Go
183 lines
7.1 KiB
Go
// 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"))
|
|
}
|
|
}
|