Merge half-past hour parsing (V-538)
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
|
||||
@@ -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, ".,!?;:«»\"'"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
{"купить три яблока", "купить три яблока"},
|
||||
|
||||
Reference in New Issue
Block a user