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:
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
|
|||||||
// or dateparser is unavailable, falls back to the stub parser. Returns
|
// or dateparser is unavailable, falls back to the stub parser. Returns
|
||||||
// (time, true, nil) on success; (zero, false, nil) when no date is found.
|
// (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) {
|
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)
|
t, ok, err := p.parseWithPython(ctx, text, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// python3 missing, dateparser not installed, or process failure —
|
// python3 missing, dateparser not installed, or process failure —
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ func afterWord(s, w string) string {
|
|||||||
type StubDateTimeParser struct{}
|
type StubDateTimeParser struct{}
|
||||||
|
|
||||||
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
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)
|
toks := strings.Fields(s)
|
||||||
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
||||||
// expression from surrounding text; the stub does the same naively.
|
// 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) ---
|
// --- 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++ {
|
for i := 0; i+1 < len(toks); i++ {
|
||||||
if toks[i] != "в" {
|
if toks[i] != "в" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t, ok := parseClock(toks[i+1], now); ok {
|
t, ok := parseClock(toks[i+1], now)
|
||||||
return t, true, nil
|
if !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if i+2 < len(toks) {
|
||||||
|
t = applyRuQualifier(t, toks[i+2], now)
|
||||||
|
}
|
||||||
|
return t, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
// "через <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()
|
y, m, d := now.AddDate(0, 0, days).Date()
|
||||||
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user