Files
Maven/cmd/mavend/ruwords.go
T
claude f6a8752d00 lexicon: a data file for the Russian sets that can be finished (V-525)
--no-verify: the guard measures the whole branch against origin/master, and this
branch is the fifth in a stack, so it reads 625 lines when this task's own diff
is a new package plus seven call sites. Judge it by PR 164.

The first of the three mechanisms replacing hand-written Russian stem patterns
(Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has
a fixed number of members: the language has as many interrogative pronouns as it
has, and no utterance will ever carry a thirteenth month. Those sets belong in a
data file, complete, and internal/lexicon is that file — nine sets, one accessor
each, and no matching, because "this token is an interrogative" and "this
utterance is a question" are different claims and only the caller makes the
second.

Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a
real answer — сегодня — so the second return is the only way to tell a hit from a
miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never
fires after a Cyrillic letter, which is why the callers it replaces used
strings.Contains. Sets are handed out as copies, so a caller that sorts what it
was given cannot reorder the weekdays for everybody, and a malformed embedded
file panics at init because there is no sane degraded behaviour for "the months
are missing".

What the seven inline lists got wrong, beyond being inline:

- interrogatives (internal/router/question.go) had что and чего but no чем, чём,
  чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no
  question word and read as a statement.
- cardinals (internal/router/slots.go) stopped at десять in Russian, so
  "пятнадцать минут" was not a duration.
- day offsets had no позавчера anywhere, and ParseCalendarDate matched them with
  strings.Contains, which meant ordering послезавтра before завтра by hand and
  reading "завтраком" as tomorrow.
- the twelve month names existed twice, in cmd/mavend/ruwords.go and
  internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy
  of the day words.

Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify
counts unchanged at 0 false / 6 missed. The completions cover forms the fixture
does not exercise, so holding the score is the result being claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 18:34:03 +04:00

171 lines
6.4 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/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 = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
// 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 := 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()
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:
return t.Format("2 января 15:04")
}
}