442d3ec08e
"напомни мне позвонить маме в семь вечера" 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.
118 lines
4.9 KiB
Go
118 lines
4.9 KiB
Go
package router
|
||
|
||
// PythonDateParser — the production DateTimeParser. Shells out to python3
|
||
// with the `dateparser` library (BSD-3, 200+ locales, ru+en native) for full
|
||
// natural-language date/time extraction: "через два часа", "в следующую
|
||
// пятницу", "завтра в 9 утра", "in 30 minutes", "on friday at noon".
|
||
//
|
||
// Falls back to StubDateTimeParser when python3 or dateparser is unavailable
|
||
// (e.g. dev runs without the Docker runtime image). The stub handles the
|
||
// common patterns; this handles the long tail.
|
||
//
|
||
// The interface is the seam — wireVoice swaps implementations without
|
||
// touching the extractor or the daemon. This struct is safe for concurrent
|
||
// use: each Parse call spawns an independent python process.
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// dateparserScript — the python inline script. Receives text as argv[1] and
|
||
// the current time as an ISO 8601 string (argv[2]). Prints the parsed
|
||
// datetime as a Unix timestamp (seconds.micros) on stdout, or nothing if no
|
||
// date was found. Exits non-zero on import failure (dateparser not
|
||
// installed).
|
||
//
|
||
// Two-step parse: search_dates finds the date substring in surrounding text
|
||
// ("напомни через час" → "через час"), then parse() re-parses that substring
|
||
// for correct time resolution (search_dates mishandles AM/PM). Russian time
|
||
// qualifiers (утра/вечера/дня/ночи) are pre-processed to AM/PM because
|
||
// dateparser drops them during substring extraction.
|
||
const dateparserScript = `import sys, re
|
||
from datetime import datetime
|
||
try:
|
||
import dateparser
|
||
from dateparser.search import search_dates
|
||
except ImportError:
|
||
sys.exit(1)
|
||
try:
|
||
text = sys.argv[1]
|
||
now = datetime.fromisoformat(sys.argv[2])
|
||
# Pre-process: replace Russian time qualifiers with AM/PM.
|
||
# Handles "9 утра", "10 часов утра", "3 часа дня" etc.
|
||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||
# Two-step: search_dates finds the date substring in text,
|
||
# parse() gets the time right (search_dates mishandles AM/PM).
|
||
results = search_dates(text, languages=['ru', 'en'], settings=settings)
|
||
if results:
|
||
substr, raw_dt = results[0]
|
||
dt = dateparser.parse(substr, languages=['ru', 'en'], settings=settings)
|
||
if dt is None:
|
||
dt = raw_dt
|
||
if dt is not None:
|
||
print(dt.timestamp())
|
||
except Exception:
|
||
pass
|
||
`
|
||
|
||
// PythonDateParser implements DateTimeParser via the python dateparser
|
||
// library, with a StubDateTimeParser fallback for environments where
|
||
// python3/dateparser isn't installed.
|
||
type PythonDateParser struct {
|
||
fallback DateTimeParser
|
||
}
|
||
|
||
// NewPythonDateParser constructs a PythonDateParser with StubDateTimeParser
|
||
// as the fallback.
|
||
func NewPythonDateParser() *PythonDateParser {
|
||
return &PythonDateParser{fallback: StubDateTimeParser{}}
|
||
}
|
||
|
||
// Parse extracts a datetime from text using python's dateparser. If python3
|
||
// 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 —
|
||
// degrade to the stub which handles the common cases.
|
||
log.Printf("router: python dateparser unavailable, falling back to stub: %v", err)
|
||
return p.fallback.Parse(ctx, text, now)
|
||
}
|
||
return t, ok, nil
|
||
}
|
||
|
||
// 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.
|
||
func (p *PythonDateParser) parseWithPython(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||
cmd := exec.CommandContext(ctx, "python3", "-c", dateparserScript, text, now.Format(time.RFC3339))
|
||
output, err := cmd.Output()
|
||
if err != nil {
|
||
return time.Time{}, false, err
|
||
}
|
||
s := strings.TrimSpace(string(output))
|
||
if s == "" {
|
||
return time.Time{}, false, nil
|
||
}
|
||
f, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return time.Time{}, false, nil
|
||
}
|
||
sec := int64(f)
|
||
nsec := int64((f - float64(sec)) * 1e9)
|
||
return time.Unix(sec, nsec).Local(), true, nil
|
||
}
|