package router import ( "strconv" "strings" "time" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/morph" ) // MentionsTime reports whether the sentence names a time at all, whether or not // a parser could read it. // // The caller is the multi-turn seam. A reminder whose time slot is empty used to // inherit the previous reminder's hour, so "напомни без четверти восемь // выходить" landed at 07:30 because the turn before it had (V-543). Inheriting // is right when the sentence names no time and wrong when it names one the // parser missed, and this is the test that tells those apart. Missing the time // he said means asking; inheriting means a wrong alarm he stops thinking about. // // Every signal here is a closed lexicon class or a digit, so this reads data and // decides nothing about meaning. func MentionsTime(text string) bool { toks := strings.Fields(strings.ToLower(text)) for i, raw := range toks { tok := cleanWord(raw) if timeMarkers[tok] { return true } if isDigitClock(tok) { return true } if _, ok := numeralDigit(tok); ok && hasTimeNeighbour(toks, i) { return true } if _, _, ok := halfPastAt(toks, i); ok { return true } if _, _, _, ok := quarterToAt(toks, i); ok { return true } if isWeekday(tok) { return true } } return false } // isDigitClock reports whether the token is a written clock, "19:30". The // minutes must be written as two digits, because a clock is and a score is not: // "счёт 3:2" names no time. func isDigitClock(tok string) bool { h, m, found := strings.Cut(tok, ":") if !found || len(m) != 2 { return false } hn, err := strconv.Atoi(h) if err != nil || hn < 0 || hn > 23 { return false } mn, err := strconv.Atoi(m) return err == nil && mn >= 0 && mn <= 59 } // NamesADay reports whether the sentence names a calendar day: a weekday, a // relative day word, or a month beside a date. A bare clock names none of them, // which is what lets a parser roll it forward to the next occurrence. // // "напомни сегодня в 12:30" names the day, so it stays on it even when 12:30 has // passed. Rolling that one forward would move a reminder he placed deliberately. func NamesADay(text string) bool { for _, raw := range strings.Fields(strings.ToLower(text)) { tok := cleanWord(raw) if _, ok := lexicon.DayOffset(tok); ok { return true } if isWeekday(tok) || isMonth(tok) { return true } } 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 } // ResolvedTheHour reports whether a parse read the hour the sentence names, // rather than inheriting the clock it was handed as its relative base. // // It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks // whether an hour was spoken and cannot ask whether it was read, so a // preposition the parser half knew wrote a reminder at 03:53 for "напомни // завтра к трём часам дня" and confirmed it as if it were the hour he said. A // wrong instant she states as fact is worse than a question, because he stops // thinking about it. // // The tell is the minute. A spoken hour lands on the hour, and the only three // shapes that name a minute of their own are a written clock, a half hour and a // quarter to. A parse that came back with any other minute took it from the // clock it was handed, whatever hour it put in front of it. An interval is // exempt, because it is measured from now and lands wherever the arithmetic // says. // // Comparing the whole instant to now would be the obvious test and it is the // wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's // reference clock is 12:00, so an hour he did say would read as an hour nobody // did. func ResolvedTheHour(text string, t time.Time) bool { if !NamesAnHour(text) { return false } if NamesAnInterval(text) || t.Minute() == 0 { return true } return namesTheMinute(text) } // namesTheMinute reports whether the sentence says which minute of the hour it // means, in any of the three ways it can. func namesTheMinute(text string) bool { toks := strings.Fields(strings.ToLower(text)) for i, raw := range toks { if isDigitClock(cleanWord(raw)) { 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, "am": true, "pm": true, "noon": true, "midnight": true, "in": true, } for _, w := range lexicon.HourUnits() { m[w] = true } for _, w := range lexicon.MinuteUnits() { m[w] = 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 { for m := 1; m <= 12; m++ { if tok == lexicon.MonthGenitive(m) { return true } } return false } // WeekdayIndex reports which day of the week a token names, in any case and in // either language, or false when it names none. // // One matcher for the whole daemon (V-581). Four files used to keep a weekday // list of their own and each one was short in a different direction: the habit // map had "воскресеньях" but no "средах", the plan refusal had "среду" but not // "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so // "среди" and "средство" read as Wednesday. The lexicon lists the nominative, // every Russian case lemmatises to it, and only English needs its forms written // out — the vendored dictionary is Russian and leaves "mondays" alone. func WeekdayIndex(tok string) (time.Weekday, bool) { t := strings.ToLower(strings.TrimSpace(tok)) if n, ok := lexicon.WeekdayEnglish(t); ok { return time.Weekday(n), true } for i, name := range lexicon.Weekdays() { if morph.SameWord(t, name) { return time.Weekday(i), true } } return 0, false } // isWeekday reports whether the token is a day of the week, when the caller // does not need to know which one. func isWeekday(tok string) bool { _, ok := WeekdayIndex(tok) return ok } // timeMarkers — the words that name a time on their own: the qualifiers that // turn an hour into a part of the day, the relative day words, the weekdays and // the two relative openers. Built from the lexicon at init, so a word added // there is a word this reads. var timeMarkers = buildTimeMarkers() func buildTimeMarkers() 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.HourUnits() { m[w] = true } for _, w := range lexicon.MinuteUnits() { m[w] = true } for _, w := range lexicon.PartsOfDay() { m[w] = true } for _, w := range lexicon.DayOffsetWords() { m[w] = true } for i := 0; i < 7; i++ { if w := lexicon.Weekday(i); w != "" { m[w] = true } } for _, w := range lexicon.HalfHourWords() { m[w] = true } for w := range minutesTo { m[w] = true } return m }