router: read the hour he said out loud (V-469)

"напомни мне позвонить маме в семь вечера" answered "не получилось разобрать
время напоминания", while "в 19:00" set the reminder. Reminders arrive through
speech, and speech says the hour in words, so this was the ordinary case
failing and the typed one working.

SpellOutDigits rewrites a spoken number as digits, but only when a time word
stands beside it — "в три часа" becomes "в 3 часа" and "купить три яблока" is
left alone. Both parsers see it: dateparser already rewrites "7 вечера" to
"7 pm" and never saw a digit to rewrite, and the stub floor now reads the
qualifier itself.
This commit is contained in:
2026-08-04 03:46:45 +04:00
parent e481ad4930
commit 442d3ec08e
4 changed files with 177 additions and 4 deletions
+3
View File
@@ -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 —
+97
View File
@@ -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
}
+38
View File
@@ -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())
}
}
+39 -4
View File
@@ -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 <num> <unit>" 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) ---
// "в <clock>" anywhere — mirror of the English "at" scan.
// "в <clock>" 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
}
// "через <N> <unit>" / "через <unit>" (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
}