diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index ad94848..908dfee 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -91,9 +91,32 @@ func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time log.Printf("router: python dateparser unavailable, falling back to stub: %v", err) return p.fallback.Parse(ctx, text, now) } + if ok { + t = rollPastClockForward(t, now, text) + } return t, ok, nil } +// rollPastClockForward moves a clock that has already gone by to its next +// occurrence. +// +// dateparser is handed PREFER_DATES_FROM future and does not apply it to an +// HH:MM time on today's date, so at 14:41 "напомни в половине первого пообедать" +// resolved to 12:30 the same day and the reminder was two hours in the past +// (V-544). parseClock in the stub has always rolled forward, so this is the +// production parser agreeing with the floor rather than a new rule. +// +// Only a bare clock rolls. A sentence that names its day keeps it, so a +// deliberate "сегодня в 12:30" stays where he put it, and the backdated write +// path (V-518) is a different seam entirely. Past by a day or more is not a +// clock resolved onto today, so it is left alone too. +func rollPastClockForward(t, now time.Time, text string) time.Time { + if t.After(now) || now.Sub(t) >= 24*time.Hour || NamesADay(text) { + return t + } + return t.Add(24 * time.Hour) +} + // parseWithPython runs the dateparser script and parses the timestamp output. // Returns (zero, false, error) on process/exec failure; (zero, false, nil) // when the script ran but found no date. diff --git a/internal/router/rollforward_test.go b/internal/router/rollforward_test.go new file mode 100644 index 0000000..e18fbcb --- /dev/null +++ b/internal/router/rollforward_test.go @@ -0,0 +1,55 @@ +package router + +import ( + "testing" + "time" +) + +// The measured defect: at 14:41 a half-past-twelve reminder was set for 12:30 +// the same day, two hours gone (V-544). +func TestRollPastClockForward(t *testing.T) { + now := time.Date(2026, 8, 5, 14, 41, 0, 0, time.Local) + day := func(d, h, m int) time.Time { + return time.Date(2026, 8, d, h, m, 0, 0, time.Local) + } + for _, tc := range []struct { + name string + in time.Time + text string + want time.Time + }{ + {"a bare clock already past rolls to tomorrow", day(5, 12, 30), "напомни в 12:30 пообедать", day(6, 12, 30)}, + {"a bare clock still to come stays", day(5, 19, 30), "напомни в 19:30 позвонить", day(5, 19, 30)}, + {"a named day keeps its day", day(5, 12, 30), "напомни сегодня в 12:30 пообедать", day(5, 12, 30)}, + {"a weekday keeps its day", day(3, 12, 30), "напомни в понедельник в 12:30", day(3, 12, 30)}, + {"a date keeps its day", day(3, 12, 30), "напомни 3 августа в 12:30", day(3, 12, 30)}, + {"past by more than a day is not a clock on today", day(4, 12, 30), "напомни в 12:30 пообедать", day(4, 12, 30)}, + } { + if got := rollPastClockForward(tc.in, now, tc.text); !got.Equal(tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got.Format("02 Jan 15:04"), tc.want.Format("02 Jan 15:04")) + } + } +} + +func TestNamesADay(t *testing.T) { + for _, s := range []string{ + "напомни сегодня в 12:30 пообедать", + "напомни завтра выпить таблетку", + "напомни в пятницу забрать заказ", + "напомни 10 июля позвонить", + } { + if !NamesADay(s) { + t.Errorf("NamesADay(%q) = false; the day is named", s) + } + } + for _, s := range []string{ + "напомни в 12:30 пообедать", + "напомни без четверти восемь выходить", + "напомни через двадцать минут", + "", + } { + if NamesADay(s) { + t.Errorf("NamesADay(%q) = true; no day is named", s) + } + } +} diff --git a/internal/router/timementions.go b/internal/router/timementions.go index c267433..09f826f 100644 --- a/internal/router/timementions.go +++ b/internal/router/timementions.go @@ -62,6 +62,36 @@ func isDigitClock(tok string) bool { return err == nil && mn >= 0 && mn <= 59 } +// NamesADay reports whether the sentence names a calendar day: a weekday, a +// relative day word, or a month beside a date. A bare clock names none of them, +// which is what lets a parser roll it forward to the next occurrence. +// +// "напомни сегодня в 12:30" names the day, so it stays on it even when 12:30 has +// passed. Rolling that one forward would move a reminder he placed deliberately. +func NamesADay(text string) bool { + for _, raw := range strings.Fields(strings.ToLower(text)) { + tok := cleanWord(raw) + if _, ok := lexicon.DayOffset(tok); ok { + return true + } + if isWeekday(tok) || isMonth(tok) { + return true + } + } + return false +} + +// isMonth reports whether the token is a month name. The lexicon holds the +// genitive, which is the form a spoken date uses: "10 июля". +func isMonth(tok string) bool { + for m := 1; m <= 12; m++ { + if tok == lexicon.MonthGenitive(m) { + return true + } + } + return false +} + // isWeekday reports whether the token is a day of the week in any case. The // lexicon lists the nominative, and "в пятницу" is what a reminder says, so the // match is by lemma — grammar is morph's job, not a second word list.