Merge the past-clock roll-forward (V-544)

This commit is contained in:
2026-08-05 15:14:29 +04:00
3 changed files with 108 additions and 0 deletions
+23
View File
@@ -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.
+55
View File
@@ -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)
}
}
}
+30
View File
@@ -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.