From 580959f856b5bbdff08dfe684a573f444a7df443 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 03:51:06 +0400 Subject: [PATCH] The hour unit has one home and it carries the dative plural (V-609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "напомни к двум часам позвонить маме" now reads two o'clock. It read no time at all, so the reminder reached the daemon with an empty slot and she asked the open "Когда?" about an hour he had just said. The word that lost it was "часам", the dative plural of "час". Four sets in internal/router listed the hour noun and every one of them stopped at "часу". They are now one lexicon key, hour_units, read by all four through lexicon.HourUnits and lexicon.IsHourUnit. The minute noun had the same gap one word over and gets the same treatment in minute_units: "минутам" was missing everywhere "минут" and "минуты" were present. The slot_value_frame set no longer lists either noun and appends both, so there is one copy of each closed class rather than a copy per caller. Two more sites had to move for the sentence to parse. hourPrepositions knew "в", "во" and "на" and not "к", and the python dateparser rewrite knew the same three. Both now read the fifth preposition and the oblique forms of the hour that follow it. Fixture unchanged: classifier+hash 27/91 before and after, reach 18/30 before and after, no case moved in either direction. Co-Authored-By: Claude Opus 5 --- internal/lexicon/lexicon.go | 37 ++++++++++++++- internal/lexicon/lexicon_ru_v1.json | 18 ++++++- internal/router/dateparser.go | 12 +++-- internal/router/hourunit_test.go | 74 +++++++++++++++++++++++++++++ internal/router/numwords.go | 21 +++++--- internal/router/slots.go | 26 ++++++---- internal/router/timementions.go | 16 +++++-- 7 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 internal/router/hourunit_test.go diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 227f3ea..0feb4b7 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -65,7 +65,7 @@ func mustLoad() lexiconFile { "day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", "filler_particles", "task_done_words", "task_drop_words", - "confirm_yes", "confirm_no", + "confirm_yes", "confirm_no", "hour_units", "minute_units", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -139,7 +139,40 @@ func TaskDropWords() []string { return words("task_drop_words") } // making the utterance a request of its own. A caller strips these (along with // the numbers and the other closed time sets) to see whether an utterance // carries any content beside the value it was asked for. See the set's note. -func SlotValueFrame() []string { return words("slot_value_frame") } +// The hour and the minute nouns are part of the frame and are kept in their own +// sets, so there is one copy of each closed class rather than a copy per caller. +func SlotValueFrame() []string { + out := words("slot_value_frame") + out = append(out, HourUnits()...) + out = append(out, MinuteUnits()...) + return out +} + +// HourUnits returns every form of the hour noun, and MinuteUnits every form of +// the minute noun. One home for each, because four router sets used to list the +// hour and all four stopped at "часу" (V-609). A caller folding time words into +// one set reads these; a caller asking about a single word reads IsHourUnit or +// IsMinuteUnit. +func HourUnits() []string { return words("hour_units") } + +// MinuteUnits — see HourUnits. +func MinuteUnits() []string { return words("minute_units") } + +// IsHourUnit reports whether a word is the hour noun in any form. +func IsHourUnit(word string) bool { return inSet("hour_units", word) } + +// IsMinuteUnit reports whether a word is the minute noun in any form. +func IsMinuteUnit(word string) bool { return inSet("minute_units", word) } + +func inSet(set, word string) bool { + w := norm(word) + for _, s := range ru.Sets[set].Words { + if w == s { + return true + } + } + return false +} // DialogueCancel returns the ways he calls off the request Maven is assembling. // Distinct from TaskDropWords, which abandons an item that already exists. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 608f0b2..8629c96 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -210,6 +210,20 @@ "передумал", "передумала", "неактуально" ] }, + "hour_units": { + "note": "Every form of the hour noun, Russian and English (V-609). One home for a closed class that four router sets used to list separately, and all four stopped at \"часу\": \"напомни к двум часам\" lost its hour and the reminder was left asking \"Когда?\". Russian declines, so the dative plural is as ordinary a way to say an hour as the accusative singular. A caller that folds time words into one set reads HourUnits; a caller asking about one word reads IsHourUnit.", + "words": [ + "час", "часа", "часов", "часу", "часам", "часами", "часах", + "hour", "hours" + ] + }, + "minute_units": { + "note": "Every form of the minute noun, Russian and English (V-609). Same class as hour_units one noun over, and it had the same gap: the dative plural \"минутам\" was missing everywhere \"минут\" and \"минуты\" were present.", + "words": [ + "минута", "минуты", "минуту", "минут", "минуте", "минутам", "минутами", "минутах", + "minute", "minutes" + ] + }, "slot_value_frame": { "note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.", "words": [ @@ -217,10 +231,10 @@ "at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for", "нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то", "no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well", - "час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня", + "полдень", "полночь", "полдня", "утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью", "сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее", - "hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now" + "noon", "midnight", "am", "pm", "oclock", "now" ] }, "dialogue_cancel": { diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index 15cd364..f15d44a 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -45,10 +45,10 @@ try: now = datetime.fromisoformat(sys.argv[2]) # Pre-process: replace Russian time qualifiers with AM/PM. # Handles "9 утра", "10 часов утра", "3 часа дня" etc. - text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE) - text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE) - text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE) - text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE) + text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE) + text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE) + text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE) + text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE) # A bare hour after a preposition is dropped on the floor by dateparser: # "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7 # часов" is read as seven hours from now. Only a qualifier (already an @@ -56,7 +56,9 @@ try: # English "at 7" fails identically, so both prepositions are rewritten. # "на 9" is the same hour said with the other preposition, and it was not # read at all until V-579: "в 9" set the reminder and "на 9" did not. - text = re.sub(r'(?