From aa7ef33bbffa3b1b6cc19e66b7aed05652e07842 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 21:40:15 +0400 Subject: [PATCH] the calendar answers his day, not any day (V-552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryCalendar matched on a day word and stepped aside only on weather wording. Every world question naming a day was claimed by it and answered with an empty schedule: "какой сегодня курс доллара" replied "на 05.08.2026 ничего нет", which reads as an answer about a subject she never looked at. All four probe utterances have an answer in search, and search sits below the calendar. V-474 fixed one instance of the class. Sunset, holidays, exchange rates and world news are the same class and weather wording does not cover them. router.IsAgendaQuestion is the narrowing. Its first arm reuses AgendaQueryGrammars, so the rule that routes a question to the query chain and the rule that lets the calendar answer it cannot drift. The second reads a scheduled-thing noun, wider than the grammars because "какие встречи завтра" carries no possessive. The third claims a question that names no subject of its own. A continuation is exempt: "а завтра?" cannot name an agenda, and this is the only date-aware source there is. --- cmd/mavend/actions_query.go | 13 +++++++ cmd/mavend/calendarq_test.go | 61 +++++++++++++++++++++++++++++++++ internal/router/agendaq.go | 57 ++++++++++++++++++++++++++++++ internal/router/agendaq_test.go | 41 ++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 cmd/mavend/calendarq_test.go create mode 100644 internal/router/agendaq.go create mode 100644 internal/router/agendaq_test.go 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) + } + } +}