diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index c51db10..ad94848 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser { // or dateparser is unavailable, falls back to the stub parser. Returns // (time, true, nil) on success; (zero, false, nil) when no date is found. func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) { + // Speech says the hour in words, and neither this parser nor the stub + // reads "в семь вечера" (Vikunja #469). Both see the digits instead. + text = SpellOutDigits(text) t, ok, err := p.parseWithPython(ctx, text, now) if err != nil { // python3 missing, dateparser not installed, or process failure — diff --git a/internal/router/numwords.go b/internal/router/numwords.go new file mode 100644 index 0000000..dbce260 --- /dev/null +++ b/internal/router/numwords.go @@ -0,0 +1,97 @@ +package router + +import "strings" + +// ruNumerals — spoken numbers as digits, for the clock hours and the minutes +// that follow them. Every case ending he might say is listed rather than +// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a +// prefix rule short enough to cover them also matches "семья". +// +// Stops at thirty, which is as far as a spoken time goes ("без двадцати +// восемь", "в половине шестого"). Anything larger is said in digits. +var ruNumerals = map[string]string{ + "один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1", + "два": "2", "две": "2", "двух": "2", + "три": "3", "трёх": "3", "трех": "3", + "четыре": "4", "четырёх": "4", "четырех": "4", + "пять": "5", "пяти": "5", + "шесть": "6", "шести": "6", + "семь": "7", "семи": "7", + "восемь": "8", "восьми": "8", + "девять": "9", "девяти": "9", + "десять": "10", "десяти": "10", + "одиннадцать": "11", "одиннадцати": "11", + "двенадцать": "12", "двенадцати": "12", + "тринадцать": "13", "тринадцати": "13", + "четырнадцать": "14", "четырнадцати": "14", + "пятнадцать": "15", "пятнадцати": "15", + "шестнадцать": "16", "шестнадцати": "16", + "семнадцать": "17", "семнадцати": "17", + "восемнадцать": "18", "восемнадцати": "18", + "девятнадцать": "19", "девятнадцати": "19", + "двадцать": "20", "двадцати": "20", + "тридцать": "30", "тридцати": "30", + "сорок": "40", "сорока": "40", + "пятьдесят": "50", "пятидесяти": "50", +} + +// numeralContext — the words that make a numeral a time. A numeral is only +// rewritten when one of these sits next to it, so "три яблока" in a note is +// left alone and "в три часа" is not. +var numeralContext = map[string]bool{ + "в": true, "во": true, "к": true, "около": true, "на": true, + "часа": true, "часов": true, "час": true, "часу": true, + "утра": true, "вечера": true, "дня": true, "ночи": true, + "минут": true, "минуты": true, "минуту": true, + "at": true, "by": true, +} + +// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the +// shape they know. +// +// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00" +// parsed fine (Vikunja #469). Speech is where reminders come from, and speech +// says the hour in words, so this is not a long-tail case — it is the ordinary +// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python +// script already does; it does not read "в семь вечера". +// +// Conservative by construction: a numeral is only rewritten when a time word +// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is, +// and a note or a fact carrying a spoken number is untouched. +func SpellOutDigits(text string) string { + toks := strings.Fields(text) + if len(toks) == 0 { + return text + } + out := make([]string, len(toks)) + copy(out, toks) + for i, tok := range toks { + key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'")) + digit, ok := ruNumerals[key] + if !ok { + continue + } + // "час" and "часу" are the hour noun as often as they are the number + // one, and rewriting "в час дня" to "в 1 дня" is right either way. What + // must not happen is rewriting the noun that gives another numeral its + // context: "в семь часов" must keep "часов". + if !hasTimeNeighbour(toks, i) { + continue + } + out[i] = digit + } + return strings.Join(out, " ") +} + +// hasTimeNeighbour reports whether the token before or after i is a time word. +func hasTimeNeighbour(toks []string, i int) bool { + for _, j := range []int{i - 1, i + 1} { + if j < 0 || j >= len(toks) { + continue + } + if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] { + return true + } + } + return false +} diff --git a/internal/router/numwords_test.go b/internal/router/numwords_test.go new file mode 100644 index 0000000..f16d854 --- /dev/null +++ b/internal/router/numwords_test.go @@ -0,0 +1,38 @@ +package router + +import ( + "context" + "testing" + "time" +) + +func TestSpellOutDigits(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"}, + {"в три часа дня", "в 3 часа дня"}, + {"напомни в половине шестого", "напомни в половине шестого"}, + {"через двадцать минут", "через 20 минут"}, + // Untouched: no time word stands beside the number. + {"купить три яблока", "купить три яблока"}, + {"семь раз отмерь", "семь раз отмерь"}, + {"напомни в 19:00", "напомни в 19:00"}, + {"", ""}, + } { + if got := SpellOutDigits(tc.in); got != tc.want { + t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// The utterance from the QA sitting that named this bug: the numeric form +// parsed and the spoken form did not. +func TestStubParsesASpokenHour(t *testing.T) { + now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local) + got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now) + if err != nil || !ok { + t.Fatalf("Parse ok=%v err=%v, want a time", ok, err) + } + if got.Hour() != 19 { + t.Fatalf("hour = %d, want 19", got.Hour()) + } +} diff --git a/internal/router/slots.go b/internal/router/slots.go index 95998a2..6a9e701 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -176,7 +176,7 @@ func afterWord(s, w string) string { type StubDateTimeParser struct{} func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) { - s := strings.ToLower(strings.TrimSpace(text)) + s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text))) toks := strings.Fields(s) // scan for "in " anywhere — dateparser extracts the datetime // expression from surrounding text; the stub does the same naively. @@ -204,14 +204,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) ( // --- Russian time expressions (stub floor; dateparser replaces) --- - // "в " anywhere — mirror of the English "at" scan. + // "в " anywhere — mirror of the English "at" scan. A qualifier + // after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and + // with SpellOutDigits in front of this that is what "в семь вечера" reads + // as too (Vikunja #469). for i := 0; i+1 < len(toks); i++ { if toks[i] != "в" { continue } - if t, ok := parseClock(toks[i+1], now); ok { - return t, true, nil + t, ok := parseClock(toks[i+1], now) + if !ok { + continue } + if i+2 < len(toks) { + t = applyRuQualifier(t, toks[i+2], now) + } + return t, true, nil } // "через " / "через " (bare = 1) / "через полчаса". @@ -476,3 +484,30 @@ func midnight(now time.Time, days int) time.Time { y, m, d := now.AddDate(0, 0, days).Date() return time.Date(y, m, d, 0, 0, 0, 0, now.Location()) } + +// applyRuQualifier moves an hour into the afternoon when he said "вечера" or +// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning +// qualifiers need no arithmetic, they only confirm the hour as spoken. +// +// The date is recomputed rather than shifted, so an hour that parseClock +// already pushed to tomorrow does not land two days out. +func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time { + h := t.Hour() + switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") { + case "вечера", "дня": + if h < 12 { + h += 12 + } + case "утра", "ночи": + if h == 12 { + h = 0 + } + default: + return t + } + out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location()) + if !out.After(now) { + out = out.Add(24 * time.Hour) + } + return out +}