Merge pull request 'Query source ordering: feeds and calendar claim turns that live search should answer' (#135) from task/474-query-source-ordering-feeds-and-calendar into master
This commit was merged in pull request #135.
This commit is contained in:
@@ -145,6 +145,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if reply, ok := src.answer(h, ctx, t); ok {
|
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
|
return reply
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,9 +290,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if !h.feedsOn {
|
if !h.feedsOn {
|
||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim only when nothing below can read the world. The reason this
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// source used to claim unconditionally was that general knowledge would
|
||||||
// news bulletin.
|
// 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
|
return "я пока не читаю ленты — они не настроены.", true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// 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
|
// 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.
|
// source can be tested at a fixed time like the rest.
|
||||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
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())
|
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -194,3 +194,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|||||||
t.Fatal("the poller fetched a private address")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
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())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
// 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
|
// explicit capture marker beats the model, which called it an act and
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
// TestExtractWeatherLocation — any place he names comes through, not just the
|
// TestExtractWeatherLocation — any place he names comes through, not just the
|
||||||
// six that used to be in a table (Vikunja #421).
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||||
// anything while its grammar set is the daemon's grammar set.
|
// anything while its grammar set is the daemon's grammar set.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||||
// in the accusative and prepositional forms the questions actually use ("в
|
// in the accusative and prepositional forms the questions actually use ("в
|
||||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||||
// than a fixed ending.
|
// than a fixed ending.
|
||||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
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
|
// the intent only: the utterance travels intact and the query chain's own
|
||||||
// matchers decide the rest.
|
// matchers decide the rest.
|
||||||
func agendaQueryBuild(m []string) (Decision, bool) {
|
func agendaQueryBuild(m []string) (Decision, bool) {
|
||||||
|
|||||||
Reference in New Issue
Block a user