From 9397f9e5f6655ab07ed83483226be2d456493fc1 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 22:32:37 +0400 Subject: [PATCH] =?UTF-8?q?query:=20only=20a=20source=20that=20knows=20the?= =?UTF-8?q?=20day=20may=20answer=20"=D0=B0=20=D0=B7=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D1=80=D0=B0=3F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up continuation re-aimed Slots.Time, but every query source matches on dec.Utterance and nothing in the chain reads it. So the inheritance bought almost nothing: "какие планы на сегодня" then "а завтра?" missed day-plan's matcher and fell to the calendar, which had parsed the day out of the raw utterance anyway. Worse than nothing in one place. queryFactByKey runs first and claims on HasKey plus HasTime, both of which the continuation sets, so a keyed query continued with "а вчера?" answered "я записала это " and dropped the day entirely. Widening the topic for every source would have made it worse still, not better: CalendarEvents is the only CoreAPI call that takes a date, so ten date-blind sources would have answered a question about tomorrow with today's data. Gate instead of widen — a continuation is only offered to a source that reads the day, and when none claims it she says so rather than "не знаю", which reads as "nothing tomorrow" when she never looked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/actions_query.go | 55 +++++++--- cmd/mavend/actions_query_continued_test.go | 116 +++++++++++++++++++++ 2 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 cmd/mavend/actions_query_continued_test.go 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) + } +}