Merge the calendar narrowing (#195)

This commit is contained in:
2026-08-05 21:41:00 +04:00
4 changed files with 197 additions and 0 deletions
+13
View File
@@ -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
+61
View File
@@ -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")
}
}
+57
View File
@@ -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*[?!.]*$`)
+66
View File
@@ -0,0 +1,66 @@
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)
}
}
}
// One hand-written utterance per agenda grammar, keyed by name. Asserting that
// the grammars pass IsAgendaQuestion would be true by construction, since the
// first arm is the loop over them. This asserts something else: that each
// grammar still matches the case its own comment gives, and that the set of
// grammars has not grown a member nobody wrote an example for.
func TestEachAgendaGrammarStillMatchesItsOwnExample(t *testing.T) {
examples := map[string]string{
"calendar-query": "что в календаре на завтра",
"agenda-query": "что у меня сегодня",
"plan-day-query": "какие планы на завтра",
"rest-of-day-query": "что дальше?",
"event-time-query": "когда планёрка",
}
for _, g := range AgendaQueryGrammars() {
u, ok := examples[g.Name]
if !ok {
t.Errorf("agenda grammar %q has no example here — add one", g.Name)
continue
}
if !g.Pattern.MatchString(u) {
t.Errorf("grammar %q no longer matches %q", g.Name, u)
}
}
}