From 96b474223d462ba268c8fb62dbbe21b54ec279b7 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:51:27 +0400 Subject: [PATCH 1/2] mavend: an unconfigured capability names the gap (V-479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netscan and the crawler both declined their own turn when the wiring was nil, and the question fell through to the search leg. "какие устройства в сети?" came back as a paragraph about routers in general, and a question about his own LAN went to an upstream engine — the personal boundary exists to stop exactly that. A URL he named came back answered as though he had not named it. Both now claim the turn once their own recogniser has matched, and say which capability is missing: net_off and page_off in the query family. TestQueryWebPassesWhenNotConfigured encoded the old decision, that announcing a configuration status is only for a capability that exists and failed. It is rewritten, not deleted: the gap is the answer now. --- cmd/mavend/actions_query.go | 20 +++++++++++--------- cmd/mavend/crawls_test.go | 16 ++++++++++------ cmd/mavend/netscan_test.go | 21 +++++++++++++++++++++ internal/phraser/query.go | 6 +++++- internal/phraser/query_ru_v1.json | 8 ++++++++ 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f019816..a07d5fd 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) } @@ -524,12 +527,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 df87124..62362fb 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) { @@ -169,3 +171,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/internal/phraser/query.go b/internal/phraser/query.go index f99d2c1..2ee3a39 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, } @@ -85,6 +87,8 @@ var queryFloor = say.RegisterFloor(map[string]string{ QueryWeatherOff: "погода не настроена.", QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", QueryNetEmpty: "в сети никого не нашла{tail}.", + QueryNetOff: "сканирование сети не настроено.", + QueryPageOff: "я не читаю страницы — это не настроено.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 249294e..ac173f8 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -62,6 +62,14 @@ "fixed": true, "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] }, + "net_off": { + "fixed": true, + "variants": ["сканирование сети не настроено."] + }, + "page_off": { + "fixed": true, + "variants": ["я не читаю страницы — это не настроено."] + }, "net_empty": { "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] }, From 1c8a32c3fd7565648c7e62a7f508ff81cdc54e64 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:56:50 +0400 Subject: [PATCH 2/2] =?UTF-8?q?router:=20stage=200=20claims=20"=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B4=D0=B0=D0=BB=D1=8C=D1=88=D0=B5=3F"=20and?= =?UTF-8?q?=20"=D1=80=D0=B0=D1=81=D1=81=D0=BA=D0=B0=D0=B6=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=20X"=20(V-498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both shapes carry no question mark and no interrogative, so the model saw them with nothing deterministic in front and routed both to fact. The fact gate caught the write and re-ran the turn as a query, so nothing broke — what they cost was a full model round trip for a decision two patterns can make offline. NarrativeQueryGrammars, wired after the agenda rules so that "расскажи, что у меня сегодня" stays an agenda question. Two exclusions, both learned from the fixture: a capture verb in the rest of the utterance means he asked for a note, and an entertainment noun means chat — "расскажи анекдот про программистов" is ru-chat-003, and my first pattern took it. The fixture had no case for either shape, which is why they went unnoticed. Added as ru-query-020 and ru-query-021: classifier+onnx 53/77 → 55/79 (68.8% → 69.6%), both new cases answered at stage 0, false clarifies unchanged at 0. --- cmd/mavend/voicewire.go | 3 ++ internal/router/agenda_test.go | 37 ++++++++++++++ internal/router/eval/eval_test.go | 1 + internal/router/eval/ru_routing_v1.json | 2 + internal/router/stage0.go | 67 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+) 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/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.