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) {