diff --git a/cmd/mavend/ordinal.go b/cmd/mavend/ordinal.go index 7b28f77..704cb6a 100644 --- a/cmd/mavend/ordinal.go +++ b/cmd/mavend/ordinal.go @@ -8,6 +8,7 @@ import ( "unicode" "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/store" ) @@ -26,44 +27,41 @@ import ( // does not say what to do with it. Acting on the bare word would guess, and a // wrong guess here closes work he never finished. -// candidateOrdinals — the words that pick a position, by index. Prefix match, -// because Russian declines them: "первый", "первую", "первое". -var candidateOrdinals = []struct { - word string - nth int -}{ - {"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5}, - {"first", 1}, {"second", 2}, {"third", 3}, -} +// The position words come from the lexicon, which lists every form with its +// position and "последний" as -1 (V-522). They used to be stem prefixes here — +// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem +// decides meaning by guessing where a word ends, and "трет" also opens +// "third-party". The lexicon runs to twelve rather than five, so he can pick +// past the fifth of a longer list; resolveCandidate already answers a position +// she did not read. // candidateDigits — "второй" said as a number. Matched whole, never by prefix: -// "15" starts with "1" and is a time, not a position. +// "15" starts with "1" and is a time, not a position. Digits are not a Russian +// word list, so they stay here rather than in the lexicon. var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5} -// candidateLast — "последний" picks the end of the list whatever its length. -var candidateLast = []string{"последн", "last"} - // parseOrdinal reads which position he named. 0 and false when he named none. // A negative result means the last one. func parseOrdinal(text string) (int, bool) { // Token by token, not substring: " 1" would otherwise match inside // "напомни в 15:00" and turn a reminder into a selection. - for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) - }) { - for _, w := range candidateLast { - if strings.HasPrefix(tok, w) { - return -1, true - } - } + }) + for i, tok := range toks { if n, ok := candidateDigits[tok]; ok { return n, true } - for _, o := range candidateOrdinals { - // Prefix, because Russian declines them: "первый", "первую". - if strings.HasPrefix(tok, o.word) { - return o.nth, true - } + // A spoken half hour names the hour it is entering with the same + // genitive ordinal: "в половине восьмого" is 07:30, not the eighth + // thing she read out. She reads a list and he answers with a time + // often enough that this has to be declined here, or the reminder + // becomes a selection. + if i > 0 && lexicon.IsHalfHour(toks[i-1]) { + continue + } + if n, ok := lexicon.Ordinal(tok); ok { + return n, true } } return 0, false diff --git a/cmd/mavend/ordinal_test.go b/cmd/mavend/ordinal_test.go index 564319d..6cef213 100644 --- a/cmd/mavend/ordinal_test.go +++ b/cmd/mavend/ordinal_test.go @@ -27,6 +27,16 @@ func TestParseOrdinalReadsThePosition(t *testing.T) { {"", 0, false}, // A digit inside a time is not a position. {"напомни в 15:00", 0, false}, + // Forms the stem list used to miss, and positions past its fifth. + {"вторым", 2, true}, + {"седьмую", 7, true}, + {"одиннадцатый", 11, true}, + // A spoken half hour names its hour with the same genitive ordinal, so + // this is 07:30 and not the eighth thing she read out (V-522). + {"напомни в половине восьмого", 0, false}, + {"полвосьмого", 0, false}, + // The ordinal still wins when the half word is not in front of it. + {"восьмую сделал", 8, true}, } for _, c := range cases { got, ok := parseOrdinal(c.text) diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index ca8b57a..b7824d9 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", "ordinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", - "not_place_after_v", "parts_of_day", "reminder_verbs", + "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -112,6 +112,23 @@ func PartsOfDay() []string { return words("parts_of_day") } // ReminderVerbs returns the imperatives that open a reminder. func ReminderVerbs() []string { return words("reminder_verbs") } +// HalfHourWords returns those forms, for a caller folding every time word into +// one set rather than asking about one word. +func HalfHourWords() []string { return words("half_hour") } + +// IsHalfHour reports whether a word introduces a spoken half hour, so the +// ordinal after it is an hour rather than a position. One caller reads that +// ordinal as the hour and another has to decline it; both ask here. +func IsHalfHour(word string) bool { + w := norm(word) + for _, h := range ru.Sets["half_hour"].Words { + if w == h { + return true + } + } + return false +} + // 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 688edbc..134a083 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -166,6 +166,13 @@ "напомни", "напомните", "напомнить", "напоминай", "remind" ] + }, + "half_hour": { + "note": "The forms of \"половина\" that introduce a spoken half hour: \"в половине восьмого\", \"к половине\", the bare \"пол\" of \"полвосьмого\". The set matters to two callers and for opposite reasons (V-522). The clock rewrite reads the ordinal after one of these as the hour being entered, and the ordinal-selection turn has to REFUSE that ordinal, because \"в половине восьмого\" names a time and not the eighth thing she read out.", + "words": [ + "половина", "половине", "половину", "половины", "пол", + "half" + ] } } } diff --git a/internal/router/halfpast.go b/internal/router/halfpast.go index 38cd4a9..498af71 100644 --- a/internal/router/halfpast.go +++ b/internal/router/halfpast.go @@ -24,13 +24,9 @@ import ( // had one and added when it did not, because the stub scans for "в" before a // clock and the contracted "полвосьмого" carries no preposition at all. -// halfWords — the forms of "половина" a spoken time uses. "в половине", -// "половина", "к половине", "полвосьмого". Closed and tiny; the ordinal beside -// them is what carries the hour, and that comes from the lexicon. -var halfWords = map[string]bool{ - "половина": true, "половине": true, "половину": true, "половины": true, - "пол": true, "half": true, -} +// The forms of "половина" a spoken time uses are a closed class and live in the +// lexicon as half_hour, because the ordinal-selection turn has to decline the +// same ordinal this file reads (V-522). Ask lexicon.IsHalfHour. // minutesTo — the words that name the minutes in a "без X" hour. "четверти" is // the only one that is not a number; the rest are cardinals and are read as @@ -74,7 +70,7 @@ func halfPastAt(toks []string, i int) (hour, width int, ok bool) { return h, 1, true } } - if !halfWords[head] || i+1 >= len(toks) { + if !lexicon.IsHalfHour(head) || i+1 >= len(toks) { return 0, 0, false } h, ok := enteredHour(cleanWord(toks[i+1])) diff --git a/internal/router/timementions.go b/internal/router/timementions.go index 09f826f..9c2e59c 100644 --- a/internal/router/timementions.go +++ b/internal/router/timementions.go @@ -129,7 +129,7 @@ func buildTimeMarkers() map[string]bool { m[w] = true } } - for w := range halfWords { + for _, w := range lexicon.HalfHourWords() { m[w] = true } for w := range minutesTo {