Files
Maven/internal/router/calendar.go
T
kami ed9bdd5e09 Add the day plan she can recite when asked (#128)
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.
2026-08-01 02:15:18 +04:00

116 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "; "))
}