The hour unit has one home and it carries the dative plural (V-609)

"напомни к двум часам позвонить маме" 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:51:06 +04:00
parent bf2587c7fa
commit 580959f856
7 changed files with 176 additions and 28 deletions
+7 -5
View File
@@ -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'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
# "к двум часам" is a third preposition and the dative that goes with it,
# and it was read as no time at all until V-609.
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
+74
View File
@@ -0,0 +1,74 @@
package router
import (
"context"
"testing"
"time"
)
// TestDativePluralHourIsAnHour — "напомни к двум часам позвонить маме" reached
// the daemon with no time at all and she asked the open "Когда?", while "к трём"
// one word over read fine (V-609). The word that lost it was "часам", the dative
// plural of "час", which four separate hour sets in this package left out.
func TestDativePluralHourIsAnHour(t *testing.T) {
const s = "напомни к двум часам позвонить маме"
if !MentionsTime(s) {
t.Errorf("MentionsTime(%q) = false; the sentence names two o'clock", s)
}
if !NamesAnHour(s) {
t.Errorf("NamesAnHour(%q) = false; the sentence names two o'clock", s)
}
if got, want := SpellOutDigits(s), "напомни к 2 часам позвонить маме"; got != want {
t.Errorf("SpellOutDigits(%q) = %q, want %q", s, got, want)
}
// The slot itself, which is what the daemon reads. It was empty, so
// whenGapOf named the hour missing and she asked "Когда?".
now := time.Date(2026, 8, 6, 3, 39, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, s, now)
if !got.HasTime {
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", got)
}
if h := got.Time.Hour(); h != 2 && h != 14 {
t.Errorf("fire time = %s, want two o'clock in one half of the day or the other", got.Time.Format("15:04"))
}
}
// TestHourUnitReachesEverySite — the four sets that read the hour noun now read
// one lexicon key, so a form added there is a form all four know. "часам" is the
// form that was missing from every one of them.
func TestHourUnitReachesEverySite(t *testing.T) {
for _, w := range []string{"час", "часа", "часов", "часу", "часам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(2, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
// TestMinuteUnitHasTheSameForms — the same defect one noun over: "минутам" was
// missing everywhere "минут" and "минуты" were present.
func TestMinuteUnitHasTheSameForms(t *testing.T) {
for _, w := range []string{"минут", "минуты", "минуту", "минутам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(20, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
+15 -6
View File
@@ -34,12 +34,21 @@ func numeralDigit(word string) (string, bool) {
// numeralContext — the words that make a numeral a time. A numeral is only
// rewritten when one of these sits next to it, so "три яблока" in a note is
// left alone and "в три часа" is not.
var numeralContext = map[string]bool{
"в": true, "во": true, "к": true, "около": true, "на": true,
"часа": true, "часов": true, "час": true, "часу": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"минут": true, "минуты": true, "минуту": true,
"at": true, "by": true,
var numeralContext = buildNumeralContext()
func buildNumeralContext() map[string]bool {
m := map[string]bool{
"в": true, "во": true, ": true, "около": true, "на": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"at": true, "by": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
return m
}
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
+17 -9
View File
@@ -175,10 +175,14 @@ func afterWord(s, w string) string {
return ""
}
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
// hourPrepositions — the words a spoken hour sits behind. Five, and no more:
// the lexicon's frame set is much wider, and a word goes in here only when the
// number after it is an hour of the day rather than a count of anything.
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
//
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
// and parsed to nothing, so the reminder reached the daemon with no time and she
// asked the open question about an hour he had just said.
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true, "к": true, "ко": true}
// StubDateTimeParser — a tiny relative/absolute parser standing in for
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
@@ -398,10 +402,18 @@ func leadingWordNumber(s string) (int, string, bool) {
}
func unitToDuration(n int, unit string) (time.Duration, bool) {
switch unit {
case "h", "hour", "hours", "hr", "hrs":
// The hour and the minute nouns are closed classes with one home in the
// lexicon, and the list here used to be short of the oblique forms (V-609).
if lexicon.IsHourUnit(unit) {
return time.Duration(n) * time.Hour, true
case "m", "min", "mins", "minute", "minutes":
}
if lexicon.IsMinuteUnit(unit) {
return time.Duration(n) * time.Minute, true
}
switch unit {
case "h", "hr", "hrs":
return time.Duration(n) * time.Hour, true
case "m", "min", "mins":
return time.Duration(n) * time.Minute, true
case "s", "sec", "secs", "second", "seconds":
return time.Duration(n) * time.Second, true
@@ -409,10 +421,6 @@ func unitToDuration(n int, unit string) (time.Duration, bool) {
case "day", "days":
return time.Duration(n) * 24 * time.Hour, true
// Russian units (inflected forms)
case "час", "часа", "часов":
return time.Duration(n) * time.Hour, true
case "минута", "минуты", "минут":
return time.Duration(n) * time.Minute, true
case "день", "дня", "дней":
return time.Duration(n) * 24 * time.Hour, true
case "неделя", "недели", "недель":
+12 -4
View File
@@ -206,12 +206,16 @@ var hourMarkers = buildHourMarkers()
func buildHourMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"часа": true, "часов": true, "час": true, "часу": true,
"минут": true, "минуты": true, "минуту": true,
"через": true, "спустя": true, "полчаса": true,
"полдень": true, "полночь": true,
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}
@@ -274,11 +278,15 @@ var timeMarkers = buildTimeMarkers()
func buildTimeMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"часа": true, "часов": true, "час": true, "часу": true,
"минут": true, "минуты": true, "минуту": true,
"через": true, "полчаса": true, "сейчас": true,
"am": true, "pm": true, "noon": true, "midnight": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}