Merge the k-preposition hour, and refuse an unresolved minute (#254)

V-610: teaching #252 that 'к' names an hour made HasTime true without
making the value resolvable, so 'напомни завтра к трём часам дня'
committed at the current clock. dateparser joins a day word to a clock
through 'в' and no other Russian preposition, so it read the day and
dropped the hour. The rewrite now normalises к, ко, на, во to в, which
also fixes 'напомни завтра на 9', silently broken the same way.

The durable half is ResolvedTheHour: both gates that read NamesAnHour
now refuse a parse whose minute nobody spoke, rather than defaulting to
the current clock. Same class as V-577. Fixture unmoved at 64/91.

(V-610)
This commit is contained in:
2026-08-06 04:12:29 +04:00
6 changed files with 250 additions and 11 deletions
+4 -3
View File
@@ -19,9 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
// time wasn't parsed. Run the parser as a fallback.
if dec.Stage == 0 && h.timeParser != nil {
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
// Same gate as the extractor (V-577, V-579): a request that named
// no hour gets asked about, never completed from the clock.
if err == nil && ok && router.NamesAnHour(dec.Utterance) {
// Same gate as the extractor (V-577, V-579, V-610): a request whose
// hour was not spoken, or was spoken and not read, gets asked about
// and is never completed from the clock.
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
dec.Slots.Time = t
dec.Slots.HasTime = true
}
+8 -1
View File
@@ -58,8 +58,15 @@ try:
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
# "к двум часам" is a third preposition and the dative that goes with it,
# and it was read as no time at all until V-609.
# The preposition is normalised as well as the hour (V-610). dateparser
# joins a day word to a clock through "в" and through no other Russian
# preposition, so "завтра к 03:00 pm" loses the clock and resolves to
# tomorrow at the CURRENT minute. "на" was silently losing it the same way.
def _at(m):
prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в'
return '%s %02d:00' % (prep, int(m.group(2)))
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)
_at, text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
# parse() gets the time right (search_dates mishandles AM/PM).
+172
View File
@@ -0,0 +1,172 @@
package router
import (
"context"
"os/exec"
"testing"
"time"
)
// probeNow is the clock the five sentences below were measured against on the
// box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a
// reminder for 03:53 itself, which is the current minute and not an hour anyone
// said (V-610).
var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local)
// kProbe — the five sentences, with what each must resolve to. The two that
// already worked are here so that fixing "к" cannot cost "в".
var kProbe = []struct {
text string
// day is the offset from probeNow's date, hour is the fire hour.
day int
hour int
whyItIs string
}{
{
text: "напомни завтра в три часа дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252",
},
{
text: "напомни завтра в 15:00 позвонить врачу",
day: 1,
hour: 15,
whyItIs: "a written clock resolves, and did before #252",
},
{
text: "напомни завтра к трём часам дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "к names the same hour в does, and wrote 03:53 after #252",
},
{
text: "напомни сегодня к пяти часам вечера позвонить врачу",
day: 0,
hour: 17,
whyItIs: "the same defect on today and on the oblique пяти",
},
{
text: "напомни завтра к трём часам позвонить врачу",
day: 1,
hour: 3,
whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half",
},
}
// TestKPrepositionHourStub — the probe against the floor parser, which answers
// on every box whether or not python is installed.
func TestKPrepositionHourStub(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
// TestKPrepositionHourPython — the same probe against the production parser.
// Skips where python3 or dateparser is missing, as the rest of this package's
// python tests do.
func TestKPrepositionHourPython(t *testing.T) {
requirePython(t)
p := NewPythonDateParser()
ex := Extractor{Time: p}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) {
t.Helper()
if fire.Hour() != hour || fire.Minute() != 0 {
t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why)
}
if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() {
t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04"))
}
want := probeNow.AddDate(0, 0, dayOffset)
if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() {
t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why)
}
}
// TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read
// the day and took the minute off the clock must not fill the time slot, no
// matter which preposition lost the hour. This is the whole class the "к" case
// was one member of.
func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) {
ex := Extractor{Time: clockEchoParser{}}
for _, s := range []string{
"напомни завтра к трём часам дня позвонить врачу",
"напомни завтра в три часа дня позвонить врачу",
"напомни завтра на девять позвонить врачу",
"напомни сегодня к пяти часам вечера позвонить врачу",
} {
got := ex.Extract(context.Background(), IntentReminder, s, probeNow)
if got.HasTime {
t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04"))
}
}
}
// TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of
// their own, and an hour that happens to be the hour it is spoken in. Refusing
// on the whole instant instead of on the minute would cost every one of these,
// and ru-rem-006 in the routing fixture is the case that says so.
func TestSpokenMinutesSurviveTheRefusal(t *testing.T) {
noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range []struct {
text string
hour int
min int
}{
{"напомни послезавтра в 12 забрать заказ", 12, 0},
{"разбуди меня в 6:30", 6, 30},
{"напомни в половине восьмого выпить таблетку", 7, 30},
{"напомни без четверти восемь выходить", 7, 45},
} {
got := ex.Extract(context.Background(), IntentReminder, c.text, noon)
if !got.HasTime {
t.Errorf("Extract(%q) left the slot empty; he said the time", c.text)
continue
}
if got.Time.Hour() != c.hour || got.Time.Minute() != c.min {
t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min)
}
}
}
// TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may
// land on now's own minute, so the refusal above must not reach it.
func TestIntervalKeepsTheCurrentMinute(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow)
if !got.HasTime {
t.Fatal("через час names one instant and answers the hour and the day together")
}
if want := probeNow.Add(time.Hour); !got.Time.Equal(want) {
t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04"))
}
}
func requirePython(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not on PATH — skipping dateparser tests")
}
if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil {
t.Skip("python dateparser not installed — skipping dateparser tests")
}
}
+6 -5
View File
@@ -55,11 +55,12 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
switch intent {
case IntentReminder:
if e.Time != nil {
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
// sentence that names a day and no hour parses to that day at the
// current minute, and filling the slot with it invents the answer
// she asked for. Left empty, the daemon asks.
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
// ResolvedTheHour is the gate, not the parser's ok (V-577, V-579,
// V-610). A sentence that names a day and no hour parses to that day
// at the current minute, and so does one whose hour the parser could
// not read. Filling the slot with either invents the answer she asked
// for. Left empty, the daemon asks.
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && ResolvedTheHour(utterance, t) {
s.Time = t
s.HasTime = true
}
+49
View File
@@ -119,6 +119,55 @@ func NamesAnHour(text string) bool {
return false
}
// ResolvedTheHour reports whether a parse read the hour the sentence names,
// rather than inheriting the clock it was handed as its relative base.
//
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
// whether an hour was spoken and cannot ask whether it was read, so a
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
// wrong instant she states as fact is worse than a question, because he stops
// thinking about it.
//
// The tell is the minute. A spoken hour lands on the hour, and the only three
// shapes that name a minute of their own are a written clock, a half hour and a
// quarter to. A parse that came back with any other minute took it from the
// clock it was handed, whatever hour it put in front of it. An interval is
// exempt, because it is measured from now and lands wherever the arithmetic
// says.
//
// Comparing the whole instant to now would be the obvious test and it is the
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
// reference clock is 12:00, so an hour he did say would read as an hour nobody
// did.
func ResolvedTheHour(text string, t time.Time) bool {
if !NamesAnHour(text) {
return false
}
if NamesAnInterval(text) || t.Minute() == 0 {
return true
}
return namesTheMinute(text)
}
// namesTheMinute reports whether the sentence says which minute of the hour it
// means, in any of the three ways it can.
func namesTheMinute(text string) bool {
toks := strings.Fields(strings.ToLower(text))
for i, raw := range toks {
if isDigitClock(cleanWord(raw)) {
return true
}
if _, _, ok := halfPastAt(toks, i); ok {
return true
}
if _, _, _, ok := quarterToAt(toks, i); ok {
return true
}
}
return false
}
// NamesAnInterval reports whether the sentence measures the time from now
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
//
+11 -2
View File
@@ -93,8 +93,17 @@ func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime {
t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04"))
}
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime {
t.Error("«на 9» names an hour and must still fill the slot")
// "на 9" names an hour, and a parser that answered with the clock did not
// read it (V-610). Naming one is necessary and reading it is what fills the
// slot, so this echo is refused too and the daemon asks.
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); got.HasTime {
t.Errorf("«на 9» took %s from the clock; the parser never read the nine", got.Time.Format("15:04"))
}
// A parser that does read it fills the slot, which is the other half of the
// same rule.
real := Extractor{Time: StubDateTimeParser{}}
if got := real.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime || got.Time.Hour() != 9 {
t.Errorf("«на 9» must fill the slot with nine o'clock, got %+v", got)
}
}