dateparser: python shell-out with two-step parse + russian qualifier pre-processing
PythonDateParser shells out to python3 with the dateparser library for full natural-language date/time extraction. Two-step approach: 1. search_dates() finds the date substring in surrounding text 2. parse() re-parses the substring for correct time resolution Russian time qualifiers (утра/вечера/дня/ночи) are pre-processed to AM/PM before parsing — dateparser drops them during substring extraction. Falls back to StubDateTimeParser when python3 or dateparser isn't available (graceful degradation, no hard runtime dependency). Dockerfile updated: python3 + dateparser==1.4.1 in runtime stage.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user