diff --git a/docs/evals/2026-08-05-half-past-hours.md b/docs/evals/2026-08-05-half-past-hours.md new file mode 100644 index 0000000..696d6d2 --- /dev/null +++ b/docs/evals/2026-08-05-half-past-hours.md @@ -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. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index f3b1291..688edbc 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -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 } }, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 610d0dc..c66ed89 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -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"] }, diff --git a/internal/router/halfpast.go b/internal/router/halfpast.go new file mode 100644 index 0000000..38cd4a9 --- /dev/null +++ b/internal/router/halfpast.go @@ -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, ".,!?;:«»\"'")) +} diff --git a/internal/router/halfpast_test.go b/internal/router/halfpast_test.go new file mode 100644 index 0000000..49e1a04 --- /dev/null +++ b/internal/router/halfpast_test.go @@ -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) + } + } +} diff --git a/internal/router/numwords.go b/internal/router/numwords.go index 8581e41..28ba9f7 100644 --- a/internal/router/numwords.go +++ b/internal/router/numwords.go @@ -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 diff --git a/internal/router/numwords_test.go b/internal/router/numwords_test.go index f16d854..cf621a1 100644 --- a/internal/router/numwords_test.go +++ b/internal/router/numwords_test.go @@ -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. {"купить три яблока", "купить три яблока"},