a clock already past rolls to its next occurrence (V-544)
At 14:41 "напомни в половине первого пообедать" was set for 12:30 the same day, two hours gone, and confirmed as "напомню сегодня в 12:30". dateparser is handed PREFER_DATES_FROM future and does not apply it to an HH:MM time on today's date. parseClock in the stub has always rolled forward, so the two parsers disagreed and the production one was the wrong half. rollPastClockForward runs on the python result. Only a bare clock rolls: a sentence naming its day keeps it, so a deliberate "сегодня в 12:30" stays where he put it, and past by a day or more is not a clock resolved onto today. NamesADay reads weekdays by lemma, the relative day words and the month names, all from the lexicon. Measured against real dateparser in a venv: "в половине первого" 05 Aug 12:30 to 06 Aug 12:30, "в 12:30" the same, "сегодня в 12:30" unchanged, and the relative and named-day cases unchanged. Left open: a reminder he places in the past is still accepted silently. Saying the hour has gone is a phrasing gap, not this fix.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user