diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 8da7e97..e694cb5 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -41,6 +41,20 @@ type queryTurn struct { type querySource struct { name string answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool) + // dateAware — this source reads the day out of the turn and answers for + // THAT day. Only such a source may claim a continuation ("а завтра?"), + // because a continuation is a question about a different day and nothing + // else. A date-blind source claiming one would answer with today's data + // under tomorrow's question, which is a wrong answer delivered in a + // confident voice — the failure mode that took reminder out of + // continuableIntents (continuation.go). + // + // Exactly one source qualifies today, and that is not an oversight in the + // table: CalendarEvents is the only CoreAPI call that takes a date at all. + // DayPlan is today-only, CurrentWeather is now-only, and the recall + // sources search text with no notion of a day. When one of them grows a + // date parameter, flip its flag here. + dateAware bool } // querySources is the ordered chain actionQuery walks; first source to claim @@ -49,53 +63,53 @@ type querySource struct { // gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one // line here plus its method; where you put the line is the whole decision. var querySources = []querySource{ - {"fact-by-key", (*reactiveHandler).queryFactByKey}, + {name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey}, // Before "calendar" on purpose: both match "…на сегодня", and the plan is // the more specific ask (its matcher requires a plan word), so the calendar // listing would otherwise swallow it. - {"day-plan", (*reactiveHandler).queryDayPlan}, + {name: "day-plan", answer: (*reactiveHandler).queryDayPlan}, // Also before "calendar": "что я обычно делаю по средам?" names a weekday, // and the habit question is the more specific one. Its matcher requires a // habit marker ("обычно", "каждый", …), so a question about this coming // Wednesday still reaches the calendar. - {"habits", (*reactiveHandler).queryHabits}, + {name: "habits", answer: (*reactiveHandler).queryHabits}, // Before "calendar" and before the recall sources: "что мне нужно // сделать?" is a question about the task list, and the notes pass would // otherwise answer it with whatever note happens to be nearest. Its // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. - {"tasks", (*reactiveHandler).queryTasks}, + {name: "tasks", answer: (*reactiveHandler).queryTasks}, // Before the recall sources too: "сколько я потратил?" is a question about // the money facts the poller wrote, and the notes pass would otherwise // answer it from whatever he once said about spending. Its matcher needs a // money noun plus an actual ask, so "я потратил весь день" is untouched. - {"money", (*reactiveHandler).queryMoney}, + {name: "money", answer: (*reactiveHandler).queryMoney}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so // "у меня новая лента в инстаграме" is untouched. - {"feeds", (*reactiveHandler).queryFeeds}, + {name: "feeds", answer: (*reactiveHandler).queryFeeds}, // Before "calendar" and before the recall sources: "что включено дома?" is // a question about the house, and the notes pass would otherwise answer it // from whatever he once said about the lights. Its matcher needs a house // marker plus an ask plus a device word, and it bails out on weather // wording, so "какая температура на улице?" still reaches the weather // source. - {"home", (*reactiveHandler).queryHome}, + {name: "home", answer: (*reactiveHandler).queryHome}, // Next to "home" and for the same reason: "какие устройства в сети?" is a // question about the LAN, and the recall pass would otherwise answer it // from an old note about the router. Its matcher needs a network word plus // an ask plus a device noun, so "интернет не работает" is untouched. - {"network", (*reactiveHandler).queryNetwork}, - {"calendar", (*reactiveHandler).queryCalendar}, - {"weather", (*reactiveHandler).queryWeather}, - {"embed", (*reactiveHandler).queryEmbed}, - {"memory", (*reactiveHandler).queryMemory}, - {"notes", (*reactiveHandler).queryNotes}, + {name: "network", answer: (*reactiveHandler).queryNetwork}, + {name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true}, + {name: "weather", answer: (*reactiveHandler).queryWeather}, + {name: "embed", answer: (*reactiveHandler).queryEmbed}, + {name: "memory", answer: (*reactiveHandler).queryMemory}, + {name: "notes", answer: (*reactiveHandler).queryNotes}, // The offline encyclopedia, after everything of his and before anything on // the network. A question he can be answered from his own notes is answered // from his own notes; only what is left over is looked up. - {"kiwix", (*reactiveHandler).queryKiwix}, + {name: "kiwix", answer: (*reactiveHandler).queryKiwix}, // LAST before the model answers from memory, and that position is the whole // design (Vikunja #259): local sources first. His memory, his notes and the // offline ZIMs all get their turn before anything touches the network. @@ -104,17 +118,26 @@ var querySources = []querySource{ // a 1.7B guessing at a page it cannot read is how contents get invented. // This source only claims a turn where he named a URL, so it never competes // with a local answer. - {"web", (*reactiveHandler).queryWeb}, - {"general-knowledge", (*reactiveHandler).queryGeneral}, + {name: "web", answer: (*reactiveHandler).queryWeb}, + {name: "general-knowledge", answer: (*reactiveHandler).queryGeneral}, } func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { t := &queryTurn{dec: dec} for _, src := range querySources { + if dec.Continued && !src.dateAware { + continue + } if reply, ok := src.answer(h, ctx, t); ok { return reply } } + if dec.Continued { + // The previous question cannot be re-asked for another day. Saying so + // beats "не знаю", which reads as "no data for tomorrow" when the + // truth is that she never looked. + return "про другой день так не отвечу — спроси целиком." + } return "не знаю." } diff --git a/cmd/mavend/actions_query_continued_test.go b/cmd/mavend/actions_query_continued_test.go new file mode 100644 index 0000000..25db580 --- /dev/null +++ b/cmd/mavend/actions_query_continued_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// contQueryAPI records which core call a continued query reached. DayPlan and +// LatestFact are here to be caught, not to be used: a continuation must never +// reach them, and the counters are how the test says so. +type contQueryAPI struct { + ipc.UnimplementedCoreAPI + from, to time.Time + events int + plans int + factLooks int +} + +func (a *contQueryAPI) CalendarEvents(_ context.Context, from, to time.Time) ([]ipc.Fact, error) { + a.events++ + a.from, a.to = from, to + return []ipc.Fact{{Key: "calendar", Value: "Планёрка @ 14:00", Confidence: 1.0, Ts: from.Add(14 * time.Hour)}}, nil +} + +func (a *contQueryAPI) DayPlan(context.Context) (ipc.DayPlan, error) { + a.plans++ + return ipc.DayPlan{Spoken: "план на сегодня"}, nil +} + +func (a *contQueryAPI) LatestFact(_ context.Context, key string) (ipc.Fact, error) { + a.factLooks++ + return ipc.Fact{Key: key, Value: "2л", Ts: contNow.Add(-time.Hour)}, nil +} + +func contQueryHandler() (*reactiveHandler, *contQueryAPI) { + api := &contQueryAPI{} + return &reactiveHandler{api: api, now: func() time.Time { return contNow }}, api +} + +// A continuation is a question about another day, so the one source that can +// read a day answers it — for the day the ellipsis named, not for today. +func TestContinuedQueryReachesTheCalendar(t *testing.T) { + h, api := contQueryHandler() + reply := h.actionQuery(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: "а завтра?", + Continued: true, + Slots: router.Slots{Text: "что у меня сегодня", Time: contNow.Add(24 * time.Hour), HasTime: true}, + }) + if api.events != 1 { + t.Fatalf("CalendarEvents called %d times, want 1", api.events) + } + if got, want := api.from.Format("2006-01-02"), "2026-08-02"; got != want { + t.Errorf("asked the calendar for %s, want %s", got, want) + } + if reply == "" { + t.Error("empty reply") + } +} + +// The regression this gate exists for: every other source is date-blind, so +// letting one claim a continuation answers a question about tomorrow with +// today's data. queryFactByKey was the live case — HasKey plus HasTime, both +// set by the continuation, and it replies with a stored fact's own timestamp. +func TestContinuedQuerySkipsDateBlindSources(t *testing.T) { + h, api := contQueryHandler() + h.actionQuery(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: "а вчера?", + Continued: true, + Slots: router.Slots{ + Key: "water", HasKey: true, + Text: "когда я пил воду", + Time: contNow.Add(-24 * time.Hour), HasTime: true, + }, + }) + if api.factLooks != 0 { + t.Errorf("fact-by-key claimed a continuation (%d lookups)", api.factLooks) + } + if api.plans != 0 { + t.Errorf("day-plan claimed a continuation (%d calls)", api.plans) + } +} + +// Nothing date-aware claimed it: say that, rather than "не знаю", which reads +// as "no data for that day" when she never looked. +func TestContinuedQueryWithNoDateAwareAnswerSaysSo(t *testing.T) { + h, _ := contQueryHandler() + // No parseable day in the utterance, so even the calendar passes. + reply := h.actionQuery(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: "а?", + Continued: true, + Slots: router.Slots{Text: "какая погода", HasTime: true}, + }) + if reply == "не знаю." || !strings.Contains(reply, "спроси целиком") { + t.Fatalf("reply = %q, want the honest continuation refusal", reply) + } +} + +// An ordinary query is untouched by the gate — every source still runs. +func TestOrdinaryQueryStillReachesEverySource(t *testing.T) { + h, api := contQueryHandler() + h.actionQuery(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: "какие планы на сегодня?", + }) + if api.plans != 1 { + t.Fatalf("day-plan called %d times on an ordinary query, want 1", api.plans) + } +}