Merge half-past hour parsing (V-538)

This commit is contained in:
2026-08-05 14:24:58 +04:00
7 changed files with 306 additions and 2 deletions
+43
View File
@@ -0,0 +1,43 @@
# Half-past and quarter-to hours, 2026-08-05
Vikunja V-538. `rewriteHalfPast` in `internal/router/halfpast.go`, run in front of
the token pass inside `SpellOutDigits`, so both date parsers see digits.
## What the shapes are
Russian names a half hour by the hour being ENTERED, in the genitive. "половина
восьмого" is 07:30. "без четверти восемь" counts the other way, from a cardinal,
and is 07:45. Both are minus one from the word in the sentence, and the arithmetic
lives in one function, `clockHourBefore`.
## Result
| | before | after |
|---|---|---|
| classifier + onnx over the routing fixture | 58/82 (70.7%) | 62/87 (71.3%) |
| new fixture cases passing | — | 2 of 3 |
| stub parser reads a half hour | no | yes |
The three new cases are ru-rem-008, ru-rem-009 and ru-rem-010. No existing case
regressed and no new clarify appeared.
ru-rem-009, "разбуди меня полвосьмого", still misses the intent. Its two siblings
without a half hour miss it the same way. ru-rem-005 "разбуди меня в 6:30" routes
to `fact`, and en-rem-002 "wake me at 6:15" does too. So the miss is the "разбуди"
phrasing against the classifier, not the half hour. The time slot now fills.
## Not measured here
Python dateparser. It is not installed on this host, so only the stub was run.
The rewrite emits "в 7:30 вечера". The script's own qualifier rewrite turns that
trailing "вечера" into "pm", which is the shape it already reads for a whole hour.
Judge it on the box.
The LLM arm. No llama-server in this run, so the cascade number is the classifier
floor.
## Left out on purpose
Minutes a spoken clock does not use. "без семи восемь" is not rewritten, because
nobody says it and a guess in this shape is a missed dose. The parsers fail on it
as they did before.
+3 -1
View File
@@ -38,7 +38,7 @@
]
},
"ordinals": {
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers.",
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers. It runs to twelve rather than ten because a clock does: \"в половине двенадцатого\" is 11:30.",
"values": {
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
@@ -50,6 +50,8 @@
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
"одиннадцатый": 11, "одиннадцатая": 11, "одиннадцатое": 11, "одиннадцатого": 11, "одиннадцатую": 11, "одиннадцатым": 11, "eleventh": 11,
"двенадцатый": 12, "двенадцатая": 12, "двенадцатое": 12, "двенадцатого": 12, "двенадцатую": 12, "двенадцатым": 12, "twelfth": 12,
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
}
},
+3
View File
@@ -58,6 +58,9 @@
{ "id": "ru-rem-005", "utterance": "разбуди меня в 6:30", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "wake-me phrasing, no напомни stem" },
{ "id": "ru-rem-006", "utterance": "напомни послезавтра в 12 забрать заказ", "lang": "ru", "intent": "reminder", "want_time": true },
{ "id": "ru-rem-007", "utterance": "через два часа напомни проверить бэкап", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["inverted"] },
{ "id": "ru-rem-008", "utterance": "напомни в половине восьмого выпить таблетку", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "half past, spoken as the ordinal of the hour being entered: 07:30, not 08:30 (V-538)" },
{ "id": "ru-rem-009", "utterance": "разбуди меня полвосьмого", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "the contracted half hour, and no preposition to hang the clock on" },
{ "id": "ru-rem-010", "utterance": "напомни без четверти восемь выходить", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "quarter to eight is 07:45, counted from a cardinal" },
{ "id": "en-rem-001", "utterance": "remind me in 45 minutes to stretch", "lang": "en", "intent": "reminder", "want_time": true },
{ "id": "en-rem-002", "utterance": "wake me at 6:15", "lang": "en", "intent": "reminder", "want_time": true, "tags": ["hard"] },
+176
View File
@@ -0,0 +1,176 @@
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, ".,!?;:«»\"'"))
}
+75
View File
@@ -0,0 +1,75 @@
package router
import (
"context"
"testing"
"time"
)
// The shape V-538 was filed for. Russian names the hour being entered, so
// "половина восьмого" is 07:30 and never 08:30. Every case here is checked
// against the hour a speaker means, not against the word in the sentence.
func TestRewriteHalfPast(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"напомни в половине восьмого", "напомни в 7:30"},
{"напомни в половине восьмого вечера", "напомни в 7:30 вечера"},
{"половина восьмого", "в 7:30"},
{"к половине девятого", "к 8:30"},
// The hour before one is twelve, not zero.
{"в половине первого", "в 12:30"},
{"в половине двенадцатого", "в 11:30"},
// Contracted, and with no preposition of its own.
{"разбуди полвосьмого", "разбуди в 7:30"},
{"разбуди пол-восьмого", "разбуди в 7:30"},
{"разбуди пол восьмого", "разбуди в 7:30"},
// Quarter-to counts the same way, from a cardinal.
{"без четверти восемь", "в 7:45"},
{"позвони без двадцати восемь", "позвони в 7:40"},
{"без пяти час", "в 12:55"},
// Untouched: not a time.
{"половина яблока", "половина яблока"},
{"без разницы восемь", "без разницы восемь"},
{"отметь последний пункт", "отметь последний пункт"},
{"напомни в 19:30", "напомни в 19:30"},
{"", ""},
} {
if got := rewriteHalfPast(tc.in); got != tc.want {
t.Errorf("rewriteHalfPast(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A guess in this shape is worse than a refusal, so the minutes a spoken clock
// does not use are left for the parsers to fail on rather than invented.
func TestQuarterToTakesOnlyClockMinutes(t *testing.T) {
for _, in := range []string{"без семи восемь", "без тринадцати восемь"} {
if got := rewriteHalfPast(in); got != in {
t.Errorf("rewriteHalfPast(%q) = %q; those minutes are not a spoken clock", in, got)
}
}
}
// The rewrite is only useful if the parser under it reads the result. The stub
// is the one that always answers, because the host path falls back to it
// whenever python dateparser is missing.
func TestStubParsesAHalfHour(t *testing.T) {
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.Local)
for _, tc := range []struct {
utt string
hour, min int
}{
{"напомни выпить таблетку в половине восьмого вечера", 19, 30},
{"напомни в половине одиннадцатого", 10, 30},
{"разбуди полвосьмого", 7, 30},
{"позвони без четверти восемь", 7, 45},
} {
got, ok, err := (StubDateTimeParser{}).Parse(context.Background(), tc.utt, now)
if err != nil || !ok {
t.Errorf("Parse(%q) = %v, %v, %v; want a time", tc.utt, got, ok, err)
continue
}
if got.Hour() != tc.hour || got.Minute() != tc.min {
t.Errorf("Parse(%q) = %02d:%02d, want %02d:%02d", tc.utt, got.Hour(), got.Minute(), tc.hour, tc.min)
}
}
}
+5
View File
@@ -55,6 +55,11 @@ var numeralContext = map[string]bool{
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
// and a note or a fact carrying a spoken number is untouched.
func SpellOutDigits(text string) string {
// A half hour and a quarter-to hour are phrases rather than numerals, and
// the hour they name is one less than the word in them (V-538). They are
// rewritten whole, before the token pass, and come out as digits it leaves
// alone.
text = rewriteHalfPast(text)
toks := strings.Fields(text)
if len(toks) == 0 {
return text
+1 -1
View File
@@ -10,7 +10,7 @@ func TestSpellOutDigits(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
{"в три часа дня", "в 3 часа дня"},
{"напомни в половине шестого", "напомни в половине шестого"},
{"напомни в половине шестого", "напомни в 5:30"},
{"через двадцать минут", "через 20 минут"},
// Untouched: no time word stands beside the number.
{"купить три яблока", "купить три яблока"},