numbers and reminder markers come from the lexicon (V-530)

ruNumerals was a second copy of the number words. It stopped at fifty, had no
oblique forms, and disagreed with lexicon_ru_v1.json about its own members, so
"к семи" was not the hour "в семь" was. The lexicon now carries the oblique
cardinals and numwords.go asks lexicon.Cardinal. "час" and "часу" stay local:
they are the hour noun as often as the number one, and nobody counts "час
яблок".

reminderbody.go built its markers from three inline word lists. Two of them
are new lexicon sets, reminder_verbs and parts_of_day, and the day offsets
were already there. The alternation helper sorts by length so a longer form
wins the regex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:18:52 +04:00
parent d9ef9ecef2
commit 997f92f5c4
4 changed files with 115 additions and 65 deletions
+35 -3
View File
@@ -2,12 +2,20 @@ package main
import (
"regexp"
"sort"
"strings"
"github.com/kami/maven/internal/lexicon"
)
// reminderMarker — the words that open a reminder. Stripped because they are
// the instruction, not the thing to say at the hour.
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
//
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
// commands she answers to, exactly like capture_verbs, and the literal that
// stood here knew four of them.
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
`)\s*(?:мне|me)?[\s,:—-]*`)
// reminderTimeWords — the time expressions a reminder carries, removed from
// the body because the fire time is already a column. Ordered longest-first
@@ -17,12 +25,36 @@ var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|на
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
// boundaries here are written out as whitespace or an end of string — the same
// trap the agenda grammars hit.
//
// The Russian word lists are gone (Vikunja #530). The day words are
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
// and were not before, and the times of day are lexicon.PartsOfDay. What is
// still written out here is the shape of a clock reading — a preposition, digits,
// a colon — which is structured input rather than a claim about Russian.
var reminderTimeWords = []*regexp.Regexp{
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
}
// alternation folds a lexicon set into one regexp branch, longest member first
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
// rather than taken as given, because two members of equal length must still
// produce the same pattern on every build.
func alternation(set []string) string {
out := make([]string, 0, len(set))
for _, w := range set {
out = append(out, regexp.QuoteMeta(w))
}
sort.Slice(out, func(i, j int) bool {
if len(out[i]) != len(out[j]) {
return len(out[i]) > len(out[j])
}
return out[i] < out[j]
})
return strings.Join(out, "|")
}
// reminderBody is what she says at the hour.
+9 -1
View File
@@ -63,7 +63,7 @@ func mustLoad() lexiconFile {
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v",
"not_place_after_v", "parts_of_day", "reminder_verbs",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -104,6 +104,14 @@ func FirstPerson() []string { return words("first_person") }
// NotPlaceAfterV returns the words that follow "в" without naming a place.
func NotPlaceAfterV() []string { return words("not_place_after_v") }
// PartsOfDay returns the one-word names for a time of day: "вечером", "утром".
// They say which part of a day and never which day, so a caller that needs the
// day wants DayOffsetWords instead.
func PartsOfDay() []string { return words("parts_of_day") }
// ReminderVerbs returns the imperatives that open a reminder.
func ReminderVerbs() []string { return words("reminder_verbs") }
// Cardinal reports the value of a spoken number word. The word is compared
// lowercased and trimmed, because it arrives from a tokenizer that may not have
// done either.
+43 -29
View File
@@ -38,37 +38,37 @@
]
},
"cardinals": {
"note": "Number words as spoken, with the gender variants Russian requires: один/одна/одно and два/две agree with the noun that follows. Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
"values": {
"ноль": 0, "нуль": 0, "zero": 0,
"один": 1, "одна": 1, "одно": 1, "one": 1,
"два": 2, "две": 2, "two": 2,
"три": 3, "three": 3,
"четыре": 4, "four": 4,
"пять": 5, "five": 5,
"шесть": 6, "six": 6,
"семь": 7, "seven": 7,
"восемь": 8, "eight": 8,
"девять": 9, "nine": 9,
"десять": 10, "ten": 10,
"одиннадцать": 11, "eleven": 11,
"двенадцать": 12, "twelve": 12,
"тринадцать": 13, "thirteen": 13,
"четырнадцать": 14, "fourteen": 14,
"пятнадцать": 15, "fifteen": 15,
"шестнадцать": 16, "sixteen": 16,
"семнадцать": 17, "seventeen": 17,
"восемнадцать": 18, "eighteen": 18,
"девятнадцать": 19, "nineteen": 19,
"двадцать": 20, "twenty": 20,
"тридцать": 30, "thirty": 30,
"сорок": 40, "forty": 40,
"пятьдесят": 50, "fifty": 50,
"шестьдесят": 60, "sixty": 60,
"семьдесят": 70, "seventy": 70,
"восемьдесят": 80, "eighty": 80,
"девяносто": 90, "ninety": 90,
"сто": 100, "hundred": 100
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1,
"два": 2, "две": 2, "двух": 2, "two": 2,
"три": 3, "трёх": 3, "трех": 3, "three": 3,
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4,
"пять": 5, "пяти": 5, "five": 5,
"шесть": 6, "шести": 6, "six": 6,
"семь": 7, "семи": 7, "seven": 7,
"восемь": 8, "восьми": 8, "eight": 8,
"девять": 9, "девяти": 9, "nine": 9,
"десять": 10, "десяти": 10, "ten": 10,
"одиннадцать": 11, "одиннадцати": 11, "eleven": 11,
"двенадцать": 12, "двенадцати": 12, "twelve": 12,
"тринадцать": 13, "тринадцати": 13, "thirteen": 13,
"четырнадцать": 14, "четырнадцати": 14, "fourteen": 14,
"пятнадцать": 15, "пятнадцати": 15, "fifteen": 15,
"шестнадцать": 16, "шестнадцати": 16, "sixteen": 16,
"семнадцать": 17, "семнадцати": 17, "seventeen": 17,
"восемнадцать": 18, "восемнадцати": 18, "eighteen": 18,
"девятнадцать": 19, "девятнадцати": 19, "nineteen": 19,
"двадцать": 20, "двадцати": 20, "twenty": 20,
"тридцать": 30, "тридцати": 30, "thirty": 30,
"сорок": 40, "сорока": 40, "forty": 40,
"пятьдесят": 50, "пятидесяти": 50, "fifty": 50,
"шестьдесят": 60, "шестидесяти": 60, "sixty": 60,
"семьдесят": 70, "семидесяти": 70, "seventy": 70,
"восемьдесят": 80, "восьмидесяти": 80, "eighty": 80,
"девяносто": 90, "девяноста": 90, "ninety": 90,
"сто": 100, "ста": 100, "hundred": 100
}
},
"day_offsets": {
@@ -134,6 +134,20 @@
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
"начале", "конце", "середине", "течение", "течении"
]
},
"parts_of_day": {
"note": "The times of day named as one word, in the instrumental case Russian uses for when something happens. A day has as many parts as it has, so this set is finished. They are not day offsets: \"вечером\" says which part of a day, never which day (Vikunja #530).",
"words": [
"утром", "днём", "днем", "вечером", "ночью",
"morning", "afternoon", "evening", "night"
]
},
"reminder_verbs": {
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).",
"words": [
"напомни", "напомните", "напомнить", "напоминай",
"remind"
]
}
}
}
+28 -32
View File
@@ -1,38 +1,34 @@
package router
import "strings"
import (
"strconv"
"strings"
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
// that follow them. Every case ending he might say is listed rather than
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
// prefix rule short enough to cover them also matches "семья".
"github.com/kami/maven/internal/lexicon"
)
// hourNouns — the two words that are the hour noun as often as they are the
// number one. "в час дня" means one o'clock, so rewriting it to "в 1 дня" is
// right either way.
//
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
// восемь", "в половине шестого"). Anything larger is said in digits.
var ruNumerals = map[string]string{
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
"два": "2", "две": "2", "двух": "2",
"три": "3", "трёх": "3", "трех": "3",
"четыре": "4", "четырёх": "4", "четырех": "4",
"пять": "5", "пяти": "5",
"шесть": "6", "шести": "6",
"семь": "7", "семи": "7",
"восемь": "8", "восьми": "8",
"девять": "9", "девяти": "9",
"десять": "10", "десяти": "10",
"одиннадцать": "11", "одиннадцати": "11",
"двенадцать": "12", "двенадцати": "12",
"тринадцать": "13", "тринадцати": "13",
"четырнадцать": "14", "четырнадцати": "14",
"пятнадцать": "15", "пятнадцати": "15",
"шестнадцать": "16", "шестнадцати": "16",
"семнадцать": "17", "семнадцати": "17",
"восемнадцать": "18", "восемнадцати": "18",
"девятнадцать": "19", "девятнадцати": "19",
"двадцать": "20", "двадцати": "20",
"тридцать": "30", "тридцати": "30",
"сорок": "40", "сорока": "40",
"пятьдесят": "50", "пятидесяти": "50",
// They are not cardinals and do not belong in the lexicon's number set: nobody
// counts "час яблок". Everything else this file reads comes from
// lexicon.Cardinal, which is where the number words live complete, oblique forms
// included (Vikunja #530). The table here used to be a second copy that stopped
// at fifty and disagreed with the lexicon about its own members.
var hourNouns = map[string]string{"час": "1", "часу": "1"}
// numeralDigit reports the digits a spoken number is written as, for a clock
// hour or the minutes after it.
func numeralDigit(word string) (string, bool) {
if d, ok := hourNouns[word]; ok {
return d, true
}
n, ok := lexicon.Cardinal(word)
if !ok {
return "", false
}
return strconv.Itoa(n), true
}
// numeralContext — the words that make a numeral a time. A numeral is only
@@ -67,7 +63,7 @@ func SpellOutDigits(text string) string {
copy(out, toks)
for i, tok := range toks {
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
digit, ok := ruNumerals[key]
digit, ok := numeralDigit(key)
if !ok {
continue
}