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) # A bare hour after a preposition is dropped on the floor by dateparser: # "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7 # часов" is read as seven hours from now. Only a qualifier (already an # am/pm above) or a colon makes it read the hour, so give it the colon. # English "at 7" fails identically, so both prepositions are rewritten. # "на 9" is the same hour said with the other preposition, and it was not # read at all until V-579: "в 9" set the reminder and "на 9" did not. # "к двум часам" is a third preposition and the dative that goes with it, # and it was read as no time at all until V-609. # The preposition is normalised as well as the hour (V-610). dateparser # joins a day word to a clock through "в" and through no other Russian # preposition, so "завтра к 03:00 pm" loses the clock and resolves to # tomorrow at the CURRENT minute. "на" was silently losing it the same way. def _at(m): prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в' return '%s %02d:00' % (prep, int(m.group(2))) text = re.sub(r'(?= 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 }