Files
Maven/internal/router/dateparser.go
T
claude 1d10c9535c The hour after "к" is read, and an hour nobody read is asked about (V-610)
"напомни завтра к трём часам дня позвонить врачу" now sets 15:00. It set 03:53,
which was the clock at the moment of the turn. She confirmed that as the hour he
had just said.

#252 taught hourPrepositions and the dateparser rewrite the preposition "к". So
HasTime and NamesAnHour started answering true for the sentence. The value did
not follow. The rewrite kept his preposition and handed dateparser "завтра к
03:00 pm". dateparser joins a day word to a clock through "в" and through no
other Russian preposition. It read the day, dropped the clock and filled the time
from its relative base. The completeness rule then saw what, time and day all
answered, and committed at the current minute.

The preposition is normalised along with the hour now. "на" was losing the clock
the same way and was never measured. So "напомни завтра на 9" was landing on the
current minute too.

The second half is the durable one. ResolvedTheHour is the gate the reminder slot
reads, and it refuses a parse whose minute nobody spoke. A spoken hour lands on
the hour. The three shapes that name a minute of their own are a written clock, a
half hour and a quarter to. Anything else came off the clock the parser was
handed. An interval is exempt, because it lands where the arithmetic says.
Comparing the whole instant to now is the obvious test and it is wrong.
ru-rem-006 resolves to 12:00 and the fixture reference clock is 12:00. That is an
hour he did say, reading as an hour nobody did.

The five sentences measured on the box are pinned as tests. They run against the
stub and against the production parser, and the two that already passed are in
there too.

Fixture unchanged. classifier+hash is 27/91 and classifier+onnx is 64/91, before
and after. reach is 18/30 and 27/30, before and after. No case moved and no
clarify count changed. Suite green under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:11:47 +04:00

159 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
_at, 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
}