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) } if ok { t = rollPastClockForward(t, now, text) } return t, ok, nil } // rollPastClockForward moves a clock that has already gone by to its next // occurrence. // // dateparser is handed PREFER_DATES_FROM future and does not apply it to an // HH:MM time on today's date, so at 14:41 "напомни в половине первого пообедать" // resolved to 12:30 the same day and the reminder was two hours in the past // (V-544). parseClock in the stub has always rolled forward, so this is the // production parser agreeing with the floor rather than a new rule. // // Only a bare clock rolls. A sentence that names its day keeps it, so a // deliberate "сегодня в 12:30" stays where he put it, and the backdated write // path (V-518) is a different seam entirely. Past by a day or more is not a // clock resolved onto today, so it is left alone too. func rollPastClockForward(t, now time.Time, text string) time.Time { if t.After(now) || now.Sub(t) >= 24*time.Hour || NamesADay(text) { return t } return t.Add(24 * time.Hour) } // 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 }