diff --git a/cmd/mavend/reminderbody.go b/cmd/mavend/reminderbody.go index 19ea305..f399668 100644 --- a/cmd/mavend/reminderbody.go +++ b/cmd/mavend/reminderbody.go @@ -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. diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index d044327..0e65770 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -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. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 15fdf20..0087e13 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -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" + ] } } } diff --git a/internal/router/numwords.go b/internal/router/numwords.go index dbce260..8581e41 100644 --- a/internal/router/numwords.go +++ b/internal/router/numwords.go @@ -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 }