dc3cda666e
At 14:41 "напомни в половине первого пообедать" was set for 12:30 the same day, two hours gone, and confirmed as "напомню сегодня в 12:30". dateparser is handed PREFER_DATES_FROM future and does not apply it to an HH:MM time on today's date. parseClock in the stub has always rolled forward, so the two parsers disagreed and the production one was the wrong half. rollPastClockForward runs on the python result. Only a bare clock rolls: a sentence naming its day keeps it, so a deliberate "сегодня в 12:30" stays where he put it, and past by a day or more is not a clock resolved onto today. NamesADay reads weekdays by lemma, the relative day words and the month names, all from the lexicon. Measured against real dateparser in a venv: "в половине первого" 05 Aug 12:30 to 06 Aug 12:30, "в 12:30" the same, "сегодня в 12:30" unchanged, and the relative and named-day cases unchanged. Left open: a reminder he places in the past is still accepted silently. Saying the hour has gone is a phrasing gap, not this fix.
141 lines
5.9 KiB
Go
141 lines
5.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)
|
||
}
|
||
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
|
||
}
|