package router import ( "strconv" "strings" "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 } // 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 } // isWeekday reports whether the token is a day of the week in any case. The // lexicon lists the nominative, and "в пятницу" is what a reminder says, so the // match is by lemma — grammar is morph's job, not a second word list. func isWeekday(tok string) bool { for i := 0; i < 7; i++ { if morph.SameWord(tok, lexicon.Weekday(i)) { return true } } return false } // 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, "часу": true, "минут": true, "минуты": true, "минуту": true, "через": true, "полчаса": true, "сейчас": true, "am": true, "pm": true, "noon": true, "midnight": 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 }