01c78ef369
Both parsers answer a bare day word with that day at the current minute, so "на завтра" set a reminder at 01:38, the minute he happened to be speaking. The gate is textual now: NamesAnHour reads the sentence, and the slot stays empty when nobody said an hour. Beside it, NamesAnInterval and HourIsAmbiguous, which the owner's commit rule reads. "на" joins "в" as a frame around a spoken hour in both parsers, a clock keeps its meaning with a full stop after it, and the stub applies a day word and a part-of-day qualifier from anywhere in the sentence rather than only from the token after the hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
6.7 KiB
Go
150 lines
6.7 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)
|
||
# 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.
|
||
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), 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
|
||
}
|