diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9f3f771..dd4ef47 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin return "", false } if h.netscan == nil { - // Fall through, same as queryHome: an unconfigured scanner must not - // swallow "сколько устройств в сети?" before recall has looked. - return "", false + // The recogniser already matched, so this is a question about HIS LAN + // and there is no scanner to answer it. Falling through sent it to the + // search leg, which answered with a paragraph about routers in general + // and put his network question on an upstream engine (Vikunja #479). + // A missing capability names itself. + return phraser.Q(phraser.QueryNetOff, nil), true } return h.netscan.scanSummary(ctx) } @@ -525,12 +528,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b return "", false } if h.crawler == nil { - // Fall through. Reading pages is off unless configured, and on a daemon - // where it was never turned on the older behaviour is right: the model - // answers the question as if the URL had not been said. Announcing a - // configuration status is for a capability that exists and failed, not - // for one he never asked for. - return "", false + // He named a URL, so the question is about that page and nothing else + // can answer it. The older comment here argued for falling through and + // letting the model answer as if the URL had not been said; that is a + // guess dressed as an answer (Vikunja #479). + return phraser.Q(phraser.QueryPageOff, nil), true } ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 4423355..b6b1882 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) { } } -// A daemon where page reading was never turned on — the default — answers the -// question the way it did before the capability existed. Claiming the turn to -// report a configuration status is for something that exists and failed. -func TestQueryWebPassesWhenNotConfigured(t *testing.T) { +// A daemon where page reading was never turned on names the gap. He asked +// about one page, nothing else on the box can read it, and the old behaviour +// here was to answer as though the URL had not been said (Vikunja #479). +func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) { h := buildWebHandler(nil) - if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok { - t.Fatalf("an unconfigured crawler claimed the turn with %q", reply) + reply, ok := askWeb(h, "посмотри https://example.org/page") + if !ok { + t.Fatal("an unconfigured crawler let the page question fall through") + } + if !phraser.IsQ(phraser.QueryPageOff, nil, reply) { + t.Errorf("got %q, want the gap named", reply) } } diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index eb10526..0adc2ce 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -10,6 +10,8 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" ) func TestWireNetScanOffUnlessEnabled(t *testing.T) { @@ -157,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) { t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n) } } + +// An unconfigured scanner names the gap instead of declining the turn. +// +// Falling through sent "какие устройства в сети?" to the search leg, which +// answered with a paragraph about routers in general — and put a question about +// his own LAN on an upstream engine, which the personal boundary exists to +// prevent (Vikunja #479). +func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) { + h := &reactiveHandler{} + reply, ok := h.queryNetwork(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "какие устройства в сети?"}, + }) + if !ok { + t.Fatal("an unconfigured scanner let the question fall through to search") + } + if !phraser.IsQ(phraser.QueryNetOff, nil, reply) { + t.Errorf("got %q, want the gap named", reply) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index f4fd334..8bf2361 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -379,6 +379,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()...) + // After the agenda rules: "расскажи, что у меня сегодня" is an agenda + // question first and a narrative request second (Vikunja #498). + grammars = append(grammars, router.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 81dd1a7..497cada 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -45,6 +45,8 @@ const ( QueryWeatherOff = "weather_off" QueryWeatherWhere = "weather_nolocation" QueryNetEmpty = "net_empty" + QueryNetOff = "net_off" + QueryPageOff = "page_off" QueryFailPlan = "fail_plan" QueryFailNotes = "fail_notes" @@ -60,7 +62,7 @@ var queryKeys = []string{ QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue, QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty, QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic, - QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, + QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff, QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, } @@ -87,6 +89,8 @@ var queryFloor = map[string]string{ QueryWeatherOff: "погода не настроена.", QueryWeatherWhere: "для какого города?", QueryNetEmpty: "в сети никого не нашла.", + QueryNetOff: "сканирование сети не настроено.", + QueryPageOff: "я не читаю страницы — это не настроено.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index a86f376..dafa5d0 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -68,6 +68,14 @@ "fixed": true, "variants": ["для какого города?"] }, + "net_off": { + "fixed": true, + "variants": ["сканирование сети не настроено."] + }, + "page_off": { + "fixed": true, + "variants": ["я не читаю страницы — это не настроено."] + }, "net_empty": { "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go index 4ef3407..7ee8420 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -75,3 +75,40 @@ func TestAgendaGrammarSparesStatements(t *testing.T) { } } } + +// The two shapes that carried no question mark and no interrogative, so the +// model saw them first and called them facts (Vikunja #498). +func TestNarrativeGrammarsRouteToQuery(t *testing.T) { + r := agendaRouter(t) + r.grammars = append(r.grammars, NarrativeQueryGrammars()...) + for _, u := range []string{ + "что дальше?", + "и что там дальше", + "what's next?", + "расскажи про битву при Ватерлоо", + "объясни как работает дизель", + "опиши Ватерлоо", + } { + d, err := r.Route(context.Background(), u, refNow()) + if err != nil { + t.Fatalf("%q: %v", u, err) + } + if d.Intent != IntentQuery || d.Stage != 0 { + t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage) + } + } +} + +// A narrative verb next to a capture verb is him asking for a note. Stage 0 +// declines and the extractor gets its turn. +func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) { + r := agendaRouter(t) + r.grammars = append(r.grammars, NarrativeQueryGrammars()...) + d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow()) + if err != nil { + t.Fatal(err) + } + if d.Stage == 0 && d.Intent == IntentQuery { + t.Errorf("stage 0 claimed a capture: %+v", d) + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 9adf2c2..89dd17c 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.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 6c765f0..2de1e25 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -28,6 +28,8 @@ { "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" }, { "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] }, { "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] }, + { "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" }, + { "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" }, { "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] }, { "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] }, { "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] }, diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 615a4fc..b02a3be 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar { } } +// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that +// carry no question mark and no interrogative, and so reached the resident +// model with nothing deterministic in front of them (Vikunja #498). +// +// Both were routed IntentFact by the model. The fact gate catches the write and +// re-runs the turn as a query, so nothing breaks today; what they cost is a full +// model round trip to reach a decision two patterns can make offline, and a +// wrong row on the routing fixture. +// +// Wired after the agenda grammars, which is where their overlap resolves: +// "расскажи, что у меня сегодня" is claimed here as a query either way. +func NarrativeQueryGrammars() []Grammar { + return []Grammar{ + { + // "что дальше?" — the rest of the day. IsRestOfDayQuery already + // recognises it downstream in the query chain, but that runs after + // the routing decision, and the routing decision was fact. + Name: "rest-of-day-query", + Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`), + Build: agendaQueryBuild, + }, + { + // "расскажи про X" — a world question phrased as an instruction. + // The lexicon is narrativeRequests, already written for the + // question-shaped test in question.go. + // + // Anchored at the start: "запиши что мне рассказали" is a capture, + // and a narrative verb buried mid-utterance is not the shape. + Name: "narrative-query", + Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`), + Build: narrativeQueryBuild, + }, + } +} + +// entertainmentNouns — what "расскажи" asks for when it is not asking for +// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make +// something up, which is the one case where inventing is the right answer +// (fixture ru-chat-003). +var entertainmentNouns = []string{ + "анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки", + "joke", "jokes", "story", +} + +// narrativeQueryBuild — the narrative shape is a query unless he also said one +// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him +// asking for a note, and stage 0 must not take either off the cascade. +func narrativeQueryBuild(m []string) (Decision, bool) { + rest := "" + if len(m) > 3 { + rest = m[3] + } + for _, t := range planTokens(rest) { + for _, v := range captureVerbs { + if t == v { + return Decision{}, false + } + } + for _, v := range entertainmentNouns { + if t == v { + return Decision{}, false + } + } + } + return agendaQueryBuild(m) +} + // agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own // matchers decide the rest.