From 01c78ef36943745f7c20222a1d35c5ecf4e6d24a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 02:20:13 +0400 Subject: [PATCH] a time slot naming no hour is asked about, never filled (V-579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both parsers answer a bare day word with that day at the current minute, so "на завтра" set a reminder at 01:38, the minute he happened to be speaking. The gate is textual now: NamesAnHour reads the sentence, and the slot stays empty when nobody said an hour. Beside it, NamesAnInterval and HourIsAmbiguous, which the owner's commit rule reads. "на" joins "в" as a frame around a spoken hour in both parsers, a clock keeps its meaning with a full stop after it, and the stub applies a day word and a part-of-day qualifier from anywhere in the sentence rather than only from the token after the hour. Co-Authored-By: Claude Opus 5 --- internal/router/dateparser.go | 4 +- internal/router/slots.go | 65 +++++++++++- internal/router/timementions.go | 142 +++++++++++++++++++++++++++ internal/router/timementions_test.go | 87 +++++++++++++++- 4 files changed, 291 insertions(+), 7 deletions(-) diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index fe13e1b..15cd364 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -54,7 +54,9 @@ try: # часов" is read as seven hours from now. Only a qualifier (already an # am/pm above) or a colon makes it read the hour, so give it the colon. # English "at 7" fails identically, so both prepositions are rewritten. - text = re.sub(r'(? " / "через " (bare = 1) / "через полчаса". @@ -307,6 +325,10 @@ func sortDescByLen(ss []string) { // parseClock — "7", "7:30" → today at that time; if already past today, roll // to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan. func parseClock(clock string, now time.Time) (time.Time, bool) { + // Speech arrives with its punctuation attached: "на 9." ends a sentence and + // still names nine o'clock (V-579). The colon is kept, since it is the one + // mark that is part of a clock. + clock = strings.Trim(clock, ".,!?;") parts := strings.SplitN(clock, ":", 2) h, err := strconv.Atoi(parts[0]) if err != nil || h < 0 || h > 23 { @@ -480,6 +502,39 @@ func midnight(now time.Time, days int) time.Time { // // The date is recomputed rather than shifted, so an hour that parseClock // already pushed to tomorrow does not land two days out. +// applyRuDayShift moves an hour onto the day the sentence names, if it names +// one. The hour is kept exactly as read: the day word says which day and says +// nothing about when in it. +// ruQualifierIn returns the first part-of-day word in the sentence, or "". +func ruQualifierIn(toks []string) string { + for _, tok := range toks { + switch cleanWord(tok) { + case "утра", "вечера", "дня", "ночи": + return cleanWord(tok) + } + } + return "" +} + +func applyRuDayShift(t time.Time, toks []string, now time.Time) time.Time { + for _, tok := range toks { + days := 0 + switch cleanWord(tok) { + case "сегодня": + days = 0 + case "завтра": + days = 1 + case "послезавтра": + days = 2 + default: + continue + } + base := now.AddDate(0, 0, days) + return time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location()) + } + return t +} + func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time { h := t.Hour() switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") { diff --git a/internal/router/timementions.go b/internal/router/timementions.go index 9c2e59c..017c746 100644 --- a/internal/router/timementions.go +++ b/internal/router/timementions.go @@ -81,6 +81,148 @@ func NamesADay(text string) bool { return false } +// NamesAnHour reports whether the sentence names a time of day or an interval +// away from now: a written clock, a numeral, a half or quarter past, or one of +// the words an interval is built from. A day word alone is not one — "завтра" +// says which day and says nothing about when in it. +// +// It is the gate on the reminder's time slot (V-577, V-579). A time slot that +// names no hour is never filled, it is asked about. Both parsers answer a bare +// day word with that day at the current minute, so "что у меня сегодня?" set a +// reminder at 01:28 and "на завтра" set one at 01:38 — the minute he happened +// to be speaking, in a request that never named one. The stub answers with +// midnight instead, which is a different invented hour and no better. +// +// Same discipline as MentionsTime above: every signal is a closed lexicon class +// or a digit, so this reads data and decides nothing about meaning. +func NamesAnHour(text string) bool { + toks := strings.Fields(strings.ToLower(text)) + for i, raw := range toks { + tok := cleanWord(raw) + if isDigitClock(tok) || isAllDigits(tok) { + return true + } + if _, ok := numeralDigit(tok); ok { + return true + } + if hourMarkers[tok] { + return true + } + if _, _, ok := halfPastAt(toks, i); ok { + return true + } + if _, _, _, ok := quarterToAt(toks, i); ok { + return true + } + } + return false +} + +// NamesAnInterval reports whether the sentence measures the time from now +// instead of naming it: "через час", "через 10 минут", "in 30 minutes". +// +// An interval resolves to one instant, so it answers the hour and the day +// together and nothing about it is ambiguous. Callers that ask which day or +// which nine o'clock have to skip it (V-579). +func NamesAnInterval(text string) bool { + for _, raw := range strings.Fields(strings.ToLower(text)) { + switch cleanWord(raw) { + case "через", "спустя", "in": + return true + } + } + return false +} + +// HourIsAmbiguous reports whether the hour named could be either half of the +// day: "в 3" is three in the afternoon or three at night, and only he knows +// which (V-579, owner's rule of 2026-08-06). +// +// Three things settle it and any one is enough. A qualifier - "вечера", "pm", +// "полдень" - says which half. A written clock says it by being written. An +// hour above twelve says it by arithmetic. An interval names no hour at all. +func HourIsAmbiguous(text string) bool { + if NamesAnInterval(text) { + return false + } + toks := strings.Fields(strings.ToLower(text)) + for _, raw := range toks { + tok := cleanWord(raw) + if hourQualifiers[tok] || isDigitClock(tok) { + return false + } + } + for _, raw := range toks { + tok := cleanWord(raw) + d, ok := numeralDigit(tok) + if !ok && isAllDigits(tok) { + d, ok = tok, true + } + if !ok { + continue + } + n, err := strconv.Atoi(d) + if err == nil && n >= 1 && n <= 12 { + return true + } + } + return false +} + +// hourQualifiers — the words that pin an hour to one half of the day. Closed, +// and every member is a lexicon class or the two English markers. +var hourQualifiers = buildHourQualifiers() + +func buildHourQualifiers() map[string]bool { + m := map[string]bool{ + "утра": true, "вечера": true, "дня": true, "ночи": true, + "полдень": true, "полночь": true, "полудня": true, + "am": true, "pm": true, "noon": true, "midnight": true, + } + for _, w := range lexicon.PartsOfDay() { + m[w] = true + } + return m +} + +func isAllDigits(tok string) bool { + if tok == "" { + return false + } + for _, r := range tok { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// hourMarkers — timeMarkers minus the day words and the weekdays, which name a +// day and not an hour, and minus "сейчас", which IS the clock and so can never +// be the evidence that the clock was meant. +var hourMarkers = buildHourMarkers() + +func buildHourMarkers() map[string]bool { + m := map[string]bool{ + "утра": true, "вечера": true, "дня": true, "ночи": true, + "часа": true, "часов": true, "час": true, "часу": true, + "минут": true, "минуты": true, "минуту": true, + "через": true, "спустя": true, "полчаса": true, + "полдень": true, "полночь": true, + "am": true, "pm": true, "noon": true, "midnight": true, "in": true, + } + for _, w := range lexicon.PartsOfDay() { + m[w] = true + } + for _, w := range lexicon.HalfHourWords() { + m[w] = true + } + for w := range minutesTo { + m[w] = true + } + return m +} + // isMonth reports whether the token is a month name. The lexicon holds the // genitive, which is the form a spoken date uses: "10 июля". func isMonth(tok string) bool { diff --git a/internal/router/timementions_test.go b/internal/router/timementions_test.go index c6d4988..06aaa77 100644 --- a/internal/router/timementions_test.go +++ b/internal/router/timementions_test.go @@ -1,6 +1,10 @@ package router -import "testing" +import ( + "context" + "testing" + "time" +) func TestMentionsTime(t *testing.T) { for _, s := range []string{ @@ -21,6 +25,87 @@ func TestMentionsTime(t *testing.T) { } } +// TestNamesAnHour — the gate on the reminder's time slot (V-577, V-579). A day +// word is not an hour, and a sentence that names no hour is asked about rather +// than completed from the clock. +func TestNamesAnHour(t *testing.T) { + for _, s := range []string{ + "в 11:00", "на 9", "в 9", "в девять", "в 9 утра", "завтра в 9", + "через час", "через двадцать минут", "в половине восьмого", + "без четверти восемь", "remind me at noon", "вечером", + } { + if !NamesAnHour(s) { + t.Errorf("NamesAnHour(%q) = false; this names an hour or an interval", s) + } + } + for _, s := range []string{ + "на завтра", "что у меня сегодня?", "напомни завтра позвонить маме", + "в пятницу", "позвонить маме", "", + } { + if NamesAnHour(s) { + t.Errorf("NamesAnHour(%q) = true; no hour was spoken, so she has to ask", s) + } + } +} + +// TestHourIsAmbiguous — the owner's rule of 2026-08-06. A bare hour is either +// half of the day and gets asked about; a qualifier, a written clock, an hour +// above twelve or an interval settles it and goes straight through. +func TestHourIsAmbiguous(t *testing.T) { + for _, s := range []string{ + "напомни завтра в 3 заказать цветы", "в 9", "на 9", "в девять", "в 11 позвонить маме", + } { + if !HourIsAmbiguous(s) { + t.Errorf("HourIsAmbiguous(%q) = false; the hour could be either half of the day", s) + } + } + for _, s := range []string{ + "напомни в 9 вечера разгрузить стиралку", "завтра в 15:00", "в 21", "в 11:00", + "через час", "через 10 минут", "remind me at noon", "напомни позвонить маме", + } { + if HourIsAmbiguous(s) { + t.Errorf("HourIsAmbiguous(%q) = true; this time reads only one way", s) + } + } +} + +// TestNamesAnInterval — an interval resolves to one instant, so it answers the +// hour and the day at once and is never asked about. +func TestNamesAnInterval(t *testing.T) { + for _, s := range []string{"через час", "через 10 минут", "через полчаса", "in 30 minutes"} { + if !NamesAnInterval(s) { + t.Errorf("NamesAnInterval(%q) = false", s) + } + } + for _, s := range []string{"завтра в 15:00", "в 9 вечера", ""} { + if NamesAnInterval(s) { + t.Errorf("NamesAnInterval(%q) = true", s) + } + } +} + +// TestReminderSlotRefusesAnHourNobodySaid — the same rule where it bites. The +// parser answers a bare day word with that day at the current minute, and the +// slot must stay empty so the daemon asks. +func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) { + now := time.Date(2026, 8, 6, 1, 38, 0, 0, time.UTC) + ex := Extractor{Time: clockEchoParser{}} + if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime { + t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04")) + } + if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime { + t.Error("«на 9» names an hour and must still fill the slot") + } +} + +// clockEchoParser stands in for what both real parsers do with a bare day word: +// it answers with the current time of day. +type clockEchoParser struct{} + +func (clockEchoParser) Parse(_ context.Context, _ string, now time.Time) (time.Time, bool, error) { + return now.AddDate(0, 0, 1), true, nil +} + // A sentence with no time in it must not read as one, or a real follow-up stops // inheriting the hour it meant. func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) {