ed9bdd5e09
The plan answers "какие планы на сегодня?" by putting one day in order: calendar events (with #126's ambient provenance carried through and hedged), pending reminders, and one line per morning routine that still has items outstanding. "что дальше?" trims what has already passed. It lives in internal/morning, not in a parallel system, because it is the same question the checklist asks at a different scale — the routine knows what is missing from a window, the plan knows what the whole day holds, and both read the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan is the impure half that reads the store. It is not a nag. Nothing here fires, schedules or announces: the plan is built only when asked, over IPC (day_plan) or on the existing /morning page. Unprompted delivery stays with the morning nudge and the dispatcher's policy. The query source sits before "calendar" in querySources because both match "…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery matches whole words so "планёрка" (a meeting) is not read as a request for the plan, and refuses any utterance naming another day, since the plan is built for the clock's own day only. Verified: make build and make test both exit 0; new tests cover plan ordering, the checklist-only-what-is-left rule, other-day rejection, the RU rendering against the persona checks, rest-of-day trimming, the source ordering, and the matcher's refusals.
116 lines
4.0 KiB
Go
116 lines
4.0 KiB
Go
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 day that is not today. The plan is built for the clock's
|
||
// own day only, so an utterance naming another one belongs to the calendar
|
||
// listing instead. Claiming it here would answer the wrong day, which is worse
|
||
// than answering more tersely.
|
||
var otherDayWords = []string{
|
||
"завтра", "послезавтра", "вчера", "позавчера",
|
||
"tomorrow", "yesterday",
|
||
}
|
||
|
||
// 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 {
|
||
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"))
|
||
}
|
||
|
||
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, "; "))
|
||
}
|