8bbdcd2727
Russian names a half hour by the hour it is entering, in the genitive, so "половина восьмого" is 07:30 and never 08:30. Neither date parser read that shape, so the reminder parsed to nothing. rewriteHalfPast runs in front of the token pass in SpellOutDigits, so the python parser and the stub both see "в 7:30". It also reads the contracted "полвосьмого" and the quarter-to shape "без четверти восемь", which counts from a cardinal and is 07:45. Minus one is in one place, clockHourBefore, with twelve rather than zero before one. Ordinals eleven and twelve added to the lexicon, because a clock reaches them. Minutes a spoken clock does not use are left alone: a guess here is a missed dose. Classifier + onnx over the routing fixture 58/82 to 62/87, three new cases, none regressed. Python dateparser is not installed on this host, so only the stub was measured. See docs/evals/2026-08-05-half-past-hours.md.
177 lines
5.7 KiB
Go
177 lines
5.7 KiB
Go
package router
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/kami/maven/internal/lexicon"
|
|
)
|
|
|
|
// Half-past and quarter-to, rewritten to digits before either date parser sees
|
|
// the sentence.
|
|
//
|
|
// Russian says a half hour as the ordinal of the hour being ENTERED, in the
|
|
// genitive: "половина восьмого" is 07:30, not 08:30. Neither python dateparser
|
|
// nor the stub reads that shape, so "напомни в половине восьмого" parsed to
|
|
// nothing (V-538). Off by one in the wrong direction is a missed dose, so the
|
|
// rewrite is the only place the arithmetic lives, and it is minus one every
|
|
// time.
|
|
//
|
|
// "без четверти восемь" counts the other way — it is 07:45, the hour NOT yet
|
|
// reached, spoken as a cardinal. Same minus one, different minutes.
|
|
//
|
|
// Both shapes come out as "в H:MM", with the preposition kept when the sentence
|
|
// had one and added when it did not, because the stub scans for "в" before a
|
|
// clock and the contracted "полвосьмого" carries no preposition at all.
|
|
|
|
// halfWords — the forms of "половина" a spoken time uses. "в половине",
|
|
// "половина", "к половине", "полвосьмого". Closed and tiny; the ordinal beside
|
|
// them is what carries the hour, and that comes from the lexicon.
|
|
var halfWords = map[string]bool{
|
|
"половина": true, "половине": true, "половину": true, "половины": true,
|
|
"пол": true, "half": true,
|
|
}
|
|
|
|
// minutesTo — the words that name the minutes in a "без X" hour. "четверти" is
|
|
// the only one that is not a number; the rest are cardinals and are read as
|
|
// cardinals.
|
|
var minutesTo = map[string]int{"четверти": 15, "quarter": 15}
|
|
|
|
// rewriteHalfPast turns a spoken half hour or quarter-to hour into digits.
|
|
// Returns text unchanged when it holds neither shape.
|
|
func rewriteHalfPast(text string) string {
|
|
toks := strings.Fields(text)
|
|
if len(toks) < 2 {
|
|
return text
|
|
}
|
|
out := make([]string, 0, len(toks))
|
|
for i := 0; i < len(toks); i++ {
|
|
if n, width, ok := halfPastAt(toks, i); ok {
|
|
out = appendClock(out, n, 30)
|
|
i += width - 1
|
|
continue
|
|
}
|
|
if h, m, width, ok := quarterToAt(toks, i); ok {
|
|
out = appendClock(out, h, m)
|
|
i += width - 1
|
|
continue
|
|
}
|
|
out = append(out, toks[i])
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
|
|
// halfPastAt reads a half hour starting at i, returning the hour it names and
|
|
// how many tokens it spans.
|
|
func halfPastAt(toks []string, i int) (hour, width int, ok bool) {
|
|
head := cleanWord(toks[i])
|
|
// The contracted one word: "полвосьмого", "пол-восьмого".
|
|
for _, prefix := range []string{"пол-", "пол"} {
|
|
if !strings.HasPrefix(head, prefix) {
|
|
continue
|
|
}
|
|
if h, ok := enteredHour(strings.TrimPrefix(head, prefix)); ok {
|
|
return h, 1, true
|
|
}
|
|
}
|
|
if !halfWords[head] || i+1 >= len(toks) {
|
|
return 0, 0, false
|
|
}
|
|
h, ok := enteredHour(cleanWord(toks[i+1]))
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
return h, 2, true
|
|
}
|
|
|
|
// quarterToAt reads a "без четверти восемь" / "без двадцати восемь" hour
|
|
// starting at i.
|
|
func quarterToAt(toks []string, i int) (hour, minute, width int, ok bool) {
|
|
if cleanWord(toks[i]) != "без" || i+2 >= len(toks) {
|
|
return 0, 0, 0, false
|
|
}
|
|
to, ok := minutesBefore(cleanWord(toks[i+1]))
|
|
if !ok {
|
|
return 0, 0, 0, false
|
|
}
|
|
h, ok := comingHour(cleanWord(toks[i+2]))
|
|
if !ok {
|
|
return 0, 0, 0, false
|
|
}
|
|
return h, 60 - to, 3, true
|
|
}
|
|
|
|
// minutesBefore reads the minutes still to run in a "без X" hour. A cardinal is
|
|
// only minutes when it divides the hour the way a spoken clock does, so "без
|
|
// семи восемь" is not a time anyone says and is left alone.
|
|
func minutesBefore(word string) (int, bool) {
|
|
if m, ok := minutesTo[word]; ok {
|
|
return m, true
|
|
}
|
|
n, ok := lexicon.Cardinal(word)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
switch n {
|
|
case 5, 10, 15, 20, 25:
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// enteredHour maps the ordinal of the hour being entered to the hour on the
|
|
// clock: "восьмого" is the eighth hour, which is 07:xx. "первого" is 12:xx,
|
|
// because the hour before one is twelve and not zero.
|
|
func enteredHour(word string) (int, bool) {
|
|
n, ok := lexicon.Ordinal(word)
|
|
if !ok || n < 1 || n > 12 {
|
|
return 0, false
|
|
}
|
|
return clockHourBefore(n), true
|
|
}
|
|
|
|
// comingHour maps the cardinal of the hour not yet reached, which counts the
|
|
// same way: "без четверти восемь" is 07:45.
|
|
func comingHour(word string) (int, bool) {
|
|
d, ok := numeralDigit(word)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(d, "%d", &n); err != nil || n < 1 || n > 12 {
|
|
return 0, false
|
|
}
|
|
return clockHourBefore(n), true
|
|
}
|
|
|
|
// clockHourBefore is the whole of the arithmetic: the hour before n, with
|
|
// twelve rather than zero before one.
|
|
func clockHourBefore(n int) int {
|
|
if n == 1 {
|
|
return 12
|
|
}
|
|
return n - 1
|
|
}
|
|
|
|
// appendClock writes the clock, keeping a preposition the sentence already had
|
|
// and adding "в" when it had none. The stub parser scans for "в" before a
|
|
// clock, and the contracted form carries no preposition.
|
|
func appendClock(out []string, hour, minute int) []string {
|
|
if n := len(out); n == 0 || !clockPrepositions[cleanWord(out[n-1])] {
|
|
out = append(out, "в")
|
|
}
|
|
return append(out, fmt.Sprintf("%d:%02d", hour, minute))
|
|
}
|
|
|
|
// clockPrepositions — the prepositions that already introduce a time, so the
|
|
// rewrite does not put a second one in front of them.
|
|
var clockPrepositions = map[string]bool{
|
|
"в": true, "во": true, "к": true, "около": true, "на": true, "at": true, "by": true,
|
|
}
|
|
|
|
// cleanWord — one token, lowercased and stripped of the punctuation a spoken
|
|
// sentence carries, the same way SpellOutDigits reads its tokens.
|
|
func cleanWord(tok string) string {
|
|
return strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
|
}
|