From 52a47729627249cd92110b379e6a796fb57b1915 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 18:16:54 +0400 Subject: [PATCH] ordinal selection asks the lexicon, and declines a half hour (V-522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon already carries every form with its position and "последний" as -1, up to twelve rather than five, so parseOrdinal reads that instead. "вторым" and "седьмую" were missed before and now land. A wider set opens one hole the stems did not have. Russian names a half hour with the genitive ordinal of the hour it is entering, so "в половине восьмого" would read as the eighth thing she read out. The forms of "половина" move into the lexicon as half_hour, where the clock rewrite in internal/router/halfpast.go and this refusal read one copy, and parseOrdinal skips an ordinal standing behind one. Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon and internal/calendar all pass. --- cmd/mavend/ordinal.go | 48 ++++++++++++++--------------- cmd/mavend/ordinal_test.go | 10 ++++++ internal/lexicon/lexicon.go | 19 +++++++++++- internal/lexicon/lexicon_ru_v1.json | 7 +++++ internal/router/halfpast.go | 12 +++----- internal/router/timementions.go | 2 +- 6 files changed, 63 insertions(+), 35 deletions(-) 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 {