Merge the ordinal lexicon change (#177)

This commit is contained in:
2026-08-05 18:17:03 +04:00
6 changed files with 63 additions and 35 deletions
+23 -25
View File
@@ -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
+10
View File
@@ -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)
+18 -1
View File
@@ -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.
+7
View File
@@ -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"
]
}
}
}
+4 -8
View File
@@ -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]))
+1 -1
View File
@@ -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 {