From 26d6d715888daaaf6fe7b4319d86d5f565f08317 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:00:58 +0400 Subject: [PATCH] mavend, router: stop three sources claiming turns the world should answer (V-474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The querySources order predates the 2026-08-02 ruling that live search leads. An unconfigured feeds source claimed every news question and answered with a configuration status, so "что происходит сейчас в новостях про искусственный интеллект?" never reached the search sitting one source below. It now claims only when neither SearXNG nor the ZIMs are configured, which is the case the "не читаю ленты" line was written for — general knowledge would otherwise invent a bulletin. The calendar matches on a day word alone and sits above the weather, so "какая сегодня погода в Москве?" answered "на 02.08.2026 ничего нет." It now steps aside on weather wording, the same bail-out queryHome already does. "что нового в лентах?" routed system and answered "пока не умею", while the same question worded with "новостях" worked. FeedQueryGrammar routes it to query at stage 0, requiring an ask word and a feed noun so the bare greeting "что нового?" stays a greeting. Wired in the eval too, since the fixture is only worth anything while its grammar set is the daemon's. Also: the claiming source is now logged. /trace is the nudge-rule trace and carries no query-source field, so a wrong answer could not be told apart from a wrongly-ordered chain. Kiwix having no live coverage is filed separately as V-508 — it is a decision about search quality, not an ordering fix. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_query.go | 30 ++++++++++++++-- cmd/mavend/feeds_test.go | 19 ++++++++++ cmd/mavend/voicewire.go | 3 ++ cmd/mavend/weatherq_test.go | 34 +++++++++++++++++- internal/router/eval/eval_test.go | 1 + internal/router/feedgrammar_test.go | 55 +++++++++++++++++++++++++++++ internal/router/stage0.go | 28 ++++++++++++++- 7 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 internal/router/feedgrammar_test.go diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 7d8b039..f507d21 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -145,6 +145,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) continue } if reply, ok := src.answer(h, ctx, t); ok { + // Which source claimed is the one thing about a query turn that was + // invisible from outside: /trace is the nudge-rule trace and carries + // no query-source field, so a wrong answer could not be told from a + // wrongly-ordered chain (Vikunja #474). Only the name is logged — + // the utterance and the answer are already on the voice lines above + // and below this one. + log.Printf("voice: query claimed by source %q", src.name) return reply } } @@ -283,9 +290,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, return "", false } if !h.feedsOn { - // Claim the turn rather than fall through: "не читаю ленты" is true, and - // letting general knowledge answer "что нового?" would be an invented - // news bulletin. + // Claim only when nothing below can read the world. The reason this + // source used to claim unconditionally was that general knowledge would + // answer "что нового?" with an invented news bulletin — true, and it + // stopped being the only alternative on 2026-08-02, when live search + // took the lead. With SearXNG or the ZIMs configured, "что происходит + // в новостях про искусственный интеллект?" has a real answer below, + // and a configuration status is the wrong thing to say instead + // (Vikunja #474). + if h.search != nil || h.kiwix != nil { + return "", false + } return "я пока не читаю ленты — они не настроены.", true } // By source, not the last 200 notes of any kind: a busy day of voice notes @@ -322,6 +337,15 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, // h.now(), not time.Now(): the handler's clock is the injected one, so this // source can be tested at a fixed time like the rest. func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) { + // A day word is all this source matches on, so any question that merely + // names a day reached it first. "какая сегодня погода в Москве?" answered + // "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day + // far more often than the calendar is, and the weather source sits right + // below, so the calendar steps aside on weather wording — the same bail-out + // queryHome already does for the same reason. + if isWeatherQuery(t.dec.Utterance) { + return "", false + } date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) if !ok { return "", false diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..84777ff 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -194,3 +194,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) { t.Fatal("the poller fetched a private address") } } + +// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The +// deployed box has no feeds block and does have SearXNG, and "что происходит +// сейчас в новостях про искусственный интеллект?" got a configuration status +// instead of the live answer sitting one source below. +func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) { + h := buildFeedHandler(t, false) + h.search = &searchWiring{max: 3, runes: 1500} + + if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok { + t.Fatalf("feeds off with a search configured must fall through, got %q", reply) + } + // With nothing below that reads the world, the honest status is still said: + // general knowledge would otherwise answer with an invented bulletin. + h.search = nil + if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") { + t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 9c47646..7bc4ccd 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -382,6 +382,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // question and must keep reaching replySystem, while "что у меня сегодня" // is an agenda question and must not. grammars = append(grammars, router.AgendaQueryGrammars()...) + // Same reason as the agenda rules, for the feeds: "что нового в лентах?" + // routed system and answered "пока не умею" (Vikunja #474). + grammars = append(grammars, router.FeedQueryGrammar()) grammars = append(grammars, router.ReminderGrammar()) // Last, and it matches any utterance shape — its Build is the filter. An // explicit capture marker beats the model, which called it an act and diff --git a/cmd/mavend/weatherq_test.go b/cmd/mavend/weatherq_test.go index b41ca9e..8e24a2f 100644 --- a/cmd/mavend/weatherq_test.go +++ b/cmd/mavend/weatherq_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "context" + "testing" + + "github.com/kami/maven/internal/router" +) // TestExtractWeatherLocation — any place he names comes through, not just the // six that used to be in a table (Vikunja #421). @@ -31,3 +36,30 @@ func TestExtractWeatherLocation(t *testing.T) { } } } + +// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня +// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches +// on a day word alone, and it sits above the weather source. +func TestCalendarStepsAsideForWeather(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 weather questions, want 0", api.events) + } + // The agenda question it exists for still reaches it. + if _, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"}, + }); !ok { + t.Fatal("the calendar stopped answering the agenda question") + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 9adf2c2..89fad15 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter // Same order as buildRouter (voicewire.go). The fixture is only worth // anything while its grammar set is the daemon's grammar set. grammars = append(grammars, router.AgendaQueryGrammars()...) + grammars = append(grammars, router.FeedQueryGrammar()) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/feedgrammar_test.go b/internal/router/feedgrammar_test.go new file mode 100644 index 0000000..025b855 --- /dev/null +++ b/internal/router/feedgrammar_test.go @@ -0,0 +1,55 @@ +package router + +import ( + "context" + "testing" +) + +func feedRouter(t *testing.T) *Router { + t.Helper() + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + r.grammars = append(r.grammars, AgendaQueryGrammars()...) + r.grammars = append(r.grammars, FeedQueryGrammar()) + return r +} + +// The documented utterance of task 258 step 1 routed system and answered +// "пока не умею отвечать на этот вопрос.", while the same question worded with +// "новостях" worked (Vikunja #474). +func TestFeedQuestionsRouteToQuery(t *testing.T) { + r := feedRouter(t) + for _, u := range []string{ + "что нового в лентах?", + "что в лентах?", + "расскажи что в новостных лентах", + "покажи ленту", + } { + d, err := r.Route(context.Background(), u, refNow()) + if err != nil { + t.Fatalf("route(%q): %v", u, err) + } + if d.Intent != IntentQuery { + t.Errorf("route(%q) = %s, want query", u, d.Intent) + } + } +} + +// The greeting and the statement keep their intents. "что нового?" is the most +// common opener in the language, and a rule that claimed it would answer hello +// with a configuration status. +func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) { + r := feedRouter(t) + for _, u := range []string{ + "что нового?", + "у меня новая лента в инстаграме", + } { + d, err := r.Route(context.Background(), u, refNow()) + if err != nil { + t.Fatalf("route(%q): %v", u, err) + } + if d.Stage == 0 { + t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent) + } + } +} diff --git a/internal/router/stage0.go b/internal/router/stage0.go index eafc89a..8b04d58 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -208,13 +208,39 @@ func AgendaQueryGrammars() []Grammar { } } +// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to +// IntentQuery so it reaches queryFeeds. +// +// Same shape of defect as the agenda grammars: the model calls it system, and +// replySystem has no feeds arm, so the documented utterance of task 258 step 1 +// answered "пока не умею отвечать на этот вопрос." while the same question +// worded with "новостях" worked (Vikunja #474). +// +// An ask word at the front and a feed noun after it are both required, which +// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting +// — the most common opener in the language — and vagueNouns in feeds.go exists +// to keep it out of the feed reader; routing it to query here would put it +// back. "у меня новая лента в инстаграме" carries the noun without the ask and +// stays the statement it is. +func FeedQueryGrammar() Grammar { + return Grammar{ + Name: "feed-query", + // (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next + // to a Cyrillic letter. + Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`), + Build: agendaQueryBuild, + } +} + // dayWordPattern — the day words an agenda question can name. Weekdays appear // in the accusative and prepositional forms the questions actually use ("в // среду", "на среде"), which is why the stems carry an inflection tail rather // than a fixed ending. const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)` -// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on +// agendaQueryBuild — shared Build for the agenda grammars and the feed one, +// which all do the same single thing: keep the utterance out of IntentSystem +// and let the query chain decide who answers it. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own // matchers decide the rest. func agendaQueryBuild(m []string) (Decision, bool) {