52a4772962
Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still
picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon
already carries every form with its position and "последний" as -1, up to
twelve rather than five, so parseOrdinal reads that instead. "вторым" and
"седьмую" were missed before and now land.
A wider set opens one hole the stems did not have. Russian names a half
hour with the genitive ordinal of the hour it is entering, so "в половине
восьмого" would read as the eighth thing she read out. The forms of
"половина" move into the lexicon as half_hour, where the clock rewrite in
internal/router/halfpast.go and this refusal read one copy, and
parseOrdinal skips an ordinal standing behind one.
Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon
and internal/calendar all pass.
173 lines
5.6 KiB
Go
173 lines
5.6 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.
|
|
|
|
// The forms of "половина" a spoken time uses are a closed class and live in the
|
|
// lexicon as half_hour, because the ordinal-selection turn has to decline the
|
|
// same ordinal this file reads (V-522). Ask lexicon.IsHalfHour.
|
|
|
|
// 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 !lexicon.IsHalfHour(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, ".,!?;:«»\"'"))
|
|
}
|