package router import ( "fmt" "strings" "time" "unicode" ) // CalendarEventFormatter formats calendar events into a Russian reply string. type CalendarEventFormatter struct{} // CalendarEntry — one event to recite. Uncertain marks an event maven did not // read off a calendar server: the work calendar arrives as relayed phone // notifications (Vikunja #126), stored below full confidence, and she says so // rather than reciting a guess as fact. type CalendarEntry struct { Text string Uncertain bool } // dayPlanWords — the tokens that ask for the day as a whole rather than for a // calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a // notification about one must not be mistaken for a request for the plan. var dayPlanWords = []string{ "план", "плана", "плану", "плане", "планом", "планы", "планов", "планам", "планах", "расписание", "расписании", "распорядок", "распорядке", "plan", "plans", "schedule", "agenda", } // otherDayWords — a span that is not the clock's own day. The plan can only be // built for today, so an utterance naming another day, a weekday, a week or a // weekend belongs to the calendar listing instead. Claiming it here would // answer today and stamp it with today's date, which is a wrong answer where // falling through is only a terse one. // // The weekday names are here as a refusal, not as a feature. "какие планы на // понедельник?" carries no other-day token in the сегодня family and does carry // "планы", so the plan used to claim it and recite today. var otherDayWords = []string{ "завтра", "послезавтра", "вчера", "позавчера", "tomorrow", "yesterday", "понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница", "субботу", "суббота", "воскресенье", "понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "неделю", "неделя", "недели", "неделе", "выходные", "выходных", "выходным", "месяц", "месяца", "месяце", "week", "weekend", "month", } // IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja // #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?". // // Deliberately narrow. The calendar listing already answers "что у меня // сегодня?" and a plan that hijacks every date-bearing question would bury the // events under checklist lines. Only a plan-shaped ask, and only about today. func IsDayPlanQuery(text string) bool { // A habit question is never a day plan, whatever words it shares with one. // "какие у меня обычно планы по вторникам?" carries "планы", so the plan // source claimed it and answered today's calendar stamped with today's // date, and the habit source never ran. Deciding it here rather than by // reordering the source table keeps one matcher from depending on the // other's position in a slice. if _, ok := ParseHabitQuery(text); ok { return false } toks := planTokens(text) for _, t := range toks { for _, w := range otherDayWords { if t == w { return false } } } for _, t := range toks { for _, w := range dayPlanWords { if t == w { return true } } } // "что дальше?" / "what's next?" — the rest of the day, with no plan word // in it. Both tokens rather than adjacency, because "what's" splits into // "what" and "s" and because "и что потом дальше" is the same question. return (hasTok(toks, "что") && hasTok(toks, "дальше")) || (hasTok(toks, "what") && hasTok(toks, "next")) } // IsRestOfDayQuery reports whether the utterance asks for what is left of the // day rather than for the whole of it — "что дальше?" and its English form. // // Tokenized for the same reason IsDayPlanQuery is: the substring form matched // "дальше" inside longer words and "next" inside "nextcloud", and the two // predicates deciding the same utterance differently is worse than either // being wrong on its own. func IsRestOfDayQuery(text string) bool { toks := planTokens(text) return hasTok(toks, "дальше") || hasTok(toks, "next") } func hasTok(toks []string, w string) bool { for _, t := range toks { if t == w { return true } } return false } // planTokens lowercases and splits on everything that is not a letter or a // digit, so "планы?" and "что-дальше" tokenize like the plain words do. func planTokens(text string) []string { return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) } // Format returns a Russian reply for the given calendar events on the given // date. Every event is treated as certain — use FormatEntries when provenance // differs between them. func (f CalendarEventFormatter) Format(events []string, date time.Time) string { entries := make([]CalendarEntry, len(events)) for i, e := range events { entries[i] = CalendarEntry{Text: e} } return f.FormatEntries(entries, date) } // FormatEntries returns a Russian reply, hedging the entries maven is not sure // about. "похоже" and not "возможно": the notification did arrive, what is // uncertain is whether it describes the meeting correctly. func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string { dateStr := date.Format("02.01.2006") if len(entries) == 0 { return fmt.Sprintf("на %s ничего нет.", dateStr) } parts := make([]string, len(entries)) for i, e := range entries { if e.Uncertain { parts[i] = "похоже, " + e.Text continue } parts[i] = e.Text } return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; ")) }