Compare commits

...

3 Commits

Author SHA1 Message Date
claude 761cf9f3e0 Merge PR #115 into task/479 (V-498) 2026-08-04 14:08:56 +02:00
claude 1c8a32c3fd router: stage 0 claims "что дальше?" and "расскажи про X" (V-498)
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.
2026-08-04 01:56:50 +04:00
claude 96b474223d mavend: an unconfigured capability names the gap (V-479)
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.
2026-08-04 01:51:27 +04:00
10 changed files with 165 additions and 16 deletions
+11 -9
View File
@@ -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()
+10 -6
View File
@@ -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)
}
}
+21
View File
@@ -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)
}
}
+3
View File
@@ -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,
+5 -1
View File
@@ -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: "не получилось посмотреть записи.",
+8
View File
@@ -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}."]
},
+37
View File
@@ -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)
}
}
+1
View File
@@ -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,
+2
View File
@@ -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"] },
+67
View File
@@ -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.