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.
This commit is contained in:
2026-08-04 01:51:27 +04:00
parent 42d7a39c49
commit 96b474223d
5 changed files with 55 additions and 16 deletions
+11 -9
View File
@@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
return "", false return "", false
} }
if h.netscan == nil { if h.netscan == nil {
// Fall through, same as queryHome: an unconfigured scanner must not // The recogniser already matched, so this is a question about HIS LAN
// swallow "сколько устройств в сети?" before recall has looked. // and there is no scanner to answer it. Falling through sent it to the
return "", false // 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) return h.netscan.scanSummary(ctx)
} }
@@ -524,12 +527,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false return "", false
} }
if h.crawler == nil { if h.crawler == nil {
// Fall through. Reading pages is off unless configured, and on a daemon // He named a URL, so the question is about that page and nothing else
// where it was never turned on the older behaviour is right: the model // can answer it. The older comment here argued for falling through and
// answers the question as if the URL had not been said. Announcing a // letting the model answer as if the URL had not been said; that is a
// configuration status is for a capability that exists and failed, not // guess dressed as an answer (Vikunja #479).
// for one he never asked for. return phraser.Q(phraser.QueryPageOff, nil), true
return "", false
} }
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() 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 // A daemon where page reading was never turned on names the gap. He asked
// question the way it did before the capability existed. Claiming the turn to // about one page, nothing else on the box can read it, and the old behaviour
// report a configuration status is for something that exists and failed. // here was to answer as though the URL had not been said (Vikunja #479).
func TestQueryWebPassesWhenNotConfigured(t *testing.T) { func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil) h := buildWebHandler(nil)
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok { reply, ok := askWeb(h, "посмотри https://example.org/page")
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply) 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/config"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
) )
func TestWireNetScanOffUnlessEnabled(t *testing.T) { 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) 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)
}
}
+5 -1
View File
@@ -45,6 +45,8 @@ const (
QueryWeatherOff = "weather_off" QueryWeatherOff = "weather_off"
QueryWeatherWhere = "weather_nolocation" QueryWeatherWhere = "weather_nolocation"
QueryNetEmpty = "net_empty" QueryNetEmpty = "net_empty"
QueryNetOff = "net_off"
QueryPageOff = "page_off"
QueryFailPlan = "fail_plan" QueryFailPlan = "fail_plan"
QueryFailNotes = "fail_notes" QueryFailNotes = "fail_notes"
@@ -60,7 +62,7 @@ var queryKeys = []string{
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue, QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty, QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic, QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
} }
@@ -85,6 +87,8 @@ var queryFloor = say.RegisterFloor(map[string]string{
QueryWeatherOff: "погода не настроена.", QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
QueryNetEmpty: "в сети никого не нашла{tail}.", QueryNetEmpty: "в сети никого не нашла{tail}.",
QueryNetOff: "сканирование сети не настроено.",
QueryPageOff: "я не читаю страницы — это не настроено.",
QueryFailPlan: "не получилось собрать план.", QueryFailPlan: "не получилось собрать план.",
QueryFailNotes: "не получилось посмотреть записи.", QueryFailNotes: "не получилось посмотреть записи.",
+8
View File
@@ -62,6 +62,14 @@
"fixed": true, "fixed": true,
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
}, },
"net_off": {
"fixed": true,
"variants": ["сканирование сети не настроено."]
},
"page_off": {
"fixed": true,
"variants": ["я не читаю страницы — это не настроено."]
},
"net_empty": { "net_empty": {
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."]
}, },