diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 1577da6..c89c3e9 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -371,6 +371,19 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri if isWeatherQuery(t.dec.Utterance) { return "", false } + // Weather was one instance of a wider class (Vikunja #552). Naming a day + // does not make a question his agenda: "какой сегодня курс доллара" and + // "во сколько закат сегодня" both answered "ничего нет", which reads as an + // answer about a subject she never looked at. All of them have an answer + // in search, and search sits below this source. So the question must ask + // about his schedule, not merely name a day. + // + // A continuation is exempt. "а завтра?" names no agenda and cannot: the + // subject was in the turn before it, and this is the only date-aware + // source there is. + if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) { + return "", false + } date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) if !ok { return "", false diff --git a/cmd/mavend/calendarq_test.go b/cmd/mavend/calendarq_test.go new file mode 100644 index 0000000..9bfb30b --- /dev/null +++ b/cmd/mavend/calendarq_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "testing" + + "github.com/kami/maven/internal/router" +) + +// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was +// one instance of a wider class, and V-474 fixed only that instance. Every one +// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and +// every one of them has an answer in search, which sits below the calendar. +func TestCalendarStepsAsideForTheWorld(t *testing.T) { + h, api := contQueryHandler() + for _, u := range []string{ + "во сколько закат сегодня", + "какой сегодня курс доллара", + "какой сегодня праздник", + "что интересного произошло сегодня в мире", + } { + if reply, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }); ok { + t.Errorf("the calendar claimed %q with %q", u, reply) + } + } + if api.events != 0 { + t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events) + } +} + +// The other half of the same narrowing: a question about his own day still +// reaches the calendar, including the one that names no subject at all. +func TestCalendarStillAnswersHisDay(t *testing.T) { + for _, u := range []string{ + "что у меня сегодня", + "во сколько у меня встреча сегодня", + "какие встречи завтра", + "что в календаре на завтра", + "что сегодня?", + } { + h, _ := contQueryHandler() + if _, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }); !ok { + t.Errorf("the calendar passed on %q", u) + } + } +} + +// A continuation carries its subject in the turn before it, and the calendar +// is the only date-aware source, so the narrowing must not reach it. +func TestCalendarStillAnswersAContinuation(t *testing.T) { + h, _ := contQueryHandler() + if _, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true}, + }); !ok { + t.Error("the calendar passed on a continuation") + } +} diff --git a/internal/router/agendaq.go b/internal/router/agendaq.go new file mode 100644 index 0000000..f4cd17f --- /dev/null +++ b/internal/router/agendaq.go @@ -0,0 +1,57 @@ +package router + +import "regexp" + +// IsAgendaQuestion answers whether an utterance asks about the owner's own +// schedule, as opposed to merely naming a day. +// +// It exists because the calendar query source used to match on a day word and +// nothing else (Vikunja #552). Every world question that happened to name a +// day was claimed by the calendar and answered with an empty schedule: "какой +// сегодня курс доллара" replied "на 05.08.2026 ничего нет", which reads as an +// answer about a subject she never looked at. V-474 had already fixed one +// instance of the class by teaching the calendar to step aside on weather +// wording. Sunset, holidays, exchange rates and world news are the same class +// and weather wording does not cover them. +// +// Three arms, and the order is only readability — any one of them is enough: +// +// - an agenda grammar already claims the phrasing. Reusing +// AgendaQueryGrammars means the rule that ROUTES a question to the query +// chain and the rule that lets the CALENDAR answer it cannot drift apart. +// - the utterance names a scheduled thing. Wider than the grammars on +// purpose: "какие встречи завтра" carries no possessive and no plan noun, +// so no grammar claims it, and it is plainly a calendar question. +// - the question names no subject of its own. "что сегодня?" is his agenda +// by default, because there is nothing else for it to be about. This is +// the same test the bare-imperative Praxis arm applies. +// +// Not a routing decision and not a fact, so a pattern is the right mechanism +// here: it selects which source answers, and every source below still runs +// when it returns false. +func IsAgendaQuestion(u string) bool { + for _, g := range AgendaQueryGrammars() { + if g.Pattern.MatchString(u) { + return true + } + } + return scheduledThing.MatchString(u) || subjectlessDayQuestion.MatchString(u) +} + +// scheduledThing — the nouns that name something on a calendar. Closed in the +// sense that matters: these are the words for an appointment itself, not the +// words for what an appointment is about. The stems are the union of the ones +// AgendaQueryGrammars already carries, read here as a noun test rather than as +// part of a phrasing. +// +// Stems and not whole words, because Russian declines them and "какие встречи" +// and "на встречу" are one question. +var scheduledThing = regexp.MustCompile(`(?i)(календар|расписани|повестк|планёрк|планерк|встреч|созвон|митинг|совещани|приём|прием|собеседовани|тренировк|занятие|занятия)`) + +// subjectlessDayQuestion — "что сегодня?", "что там на завтра", "что в среду". +// An interrogative, an optional preposition, a day word, and nothing else. The +// anchors at both ends are the whole point: the moment the sentence names what +// it is asking about, it stops being his agenda and this must not match. +var subjectlessDayQuestion = regexp.MustCompile( + `(?i)^\s*(что|чего|какие|сколько|what)\s+(там\s+|ещё\s+|еще\s+)?(на\s+|в\s+|во\s+)?` + + dayWordPattern + `\s*[?!.]*$`) diff --git a/internal/router/agendaq_test.go b/internal/router/agendaq_test.go new file mode 100644 index 0000000..2421ee0 --- /dev/null +++ b/internal/router/agendaq_test.go @@ -0,0 +1,41 @@ +package router + +import "testing" + +// The four utterances in Vikunja #552 plus the ones that must keep reaching +// the calendar. The list is the whole point of the predicate: every "want +// false" row was answered "на 05.08.2026 ничего нет" on the deployed daemon. +func TestIsAgendaQuestionSeparatesHisDayFromTheWorld(t *testing.T) { + tests := []struct { + utterance string + want bool + }{ + // His day. + {"что у меня сегодня", true}, + {"во сколько у меня встреча сегодня", true}, + {"что в календаре на завтра", true}, + {"какие планы на завтра", true}, + {"какие встречи завтра", true}, + {"когда планёрка", true}, + {"покажи расписание на среду", true}, + {"что дальше?", true}, + // No subject of its own, so his day by default. + {"что сегодня?", true}, + {"что на завтра", true}, + {"что там в среду?", true}, + + // The world, naming a day. Every one of these is #552. + {"во сколько закат сегодня", false}, + {"какой сегодня курс доллара", false}, + {"какой сегодня праздник", false}, + {"что интересного произошло сегодня в мире", false}, + {"кто выиграл вчера матч", false}, + // A day word plus a subject is never subjectless, however short. + {"что за праздник сегодня", false}, + } + for _, tt := range tests { + if got := IsAgendaQuestion(tt.utterance); got != tt.want { + t.Errorf("IsAgendaQuestion(%q) = %v, want %v", tt.utterance, got, tt.want) + } + } +}