Files
Maven/internal/router/kpreposition_test.go
claude 1d10c9535c The hour after "к" is read, and an hour nobody read is asked about (V-610)
"напомни завтра к трём часам дня позвонить врачу" now sets 15:00. It set 03:53,
which was the clock at the moment of the turn. She confirmed that as the hour he
had just said.

#252 taught hourPrepositions and the dateparser rewrite the preposition "к". So
HasTime and NamesAnHour started answering true for the sentence. The value did
not follow. The rewrite kept his preposition and handed dateparser "завтра к
03:00 pm". dateparser joins a day word to a clock through "в" and through no
other Russian preposition. It read the day, dropped the clock and filled the time
from its relative base. The completeness rule then saw what, time and day all
answered, and committed at the current minute.

The preposition is normalised along with the hour now. "на" was losing the clock
the same way and was never measured. So "напомни завтра на 9" was landing on the
current minute too.

The second half is the durable one. ResolvedTheHour is the gate the reminder slot
reads, and it refuses a parse whose minute nobody spoke. A spoken hour lands on
the hour. The three shapes that name a minute of their own are a written clock, a
half hour and a quarter to. Anything else came off the clock the parser was
handed. An interval is exempt, because it lands where the arithmetic says.
Comparing the whole instant to now is the obvious test and it is wrong.
ru-rem-006 resolves to 12:00 and the fixture reference clock is 12:00. That is an
hour he did say, reading as an hour nobody did.

The five sentences measured on the box are pinned as tests. They run against the
stub and against the production parser, and the two that already passed are in
there too.

Fixture unchanged. classifier+hash is 27/91 and classifier+onnx is 64/91, before
and after. reach is 18/30 and 27/30, before and after. No case moved and no
clarify count changed. Suite green under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:11:47 +04:00

173 lines
6.5 KiB
Go

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")
}
}