Merge the calendar narrowing (#195)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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*[?!.]*$`)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user