diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9629303..7d8b039 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -81,6 +81,11 @@ var querySources = []querySource{ // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. {name: "tasks", answer: (*reactiveHandler).queryTasks}, + // Next to "tasks" and for the same reason: "что требует внимания?" is a + // question about the operational state Praxis holds, and it used to fall + // through every source to the web search (Vikunja #475). Its matcher needs + // an attention marker, and it falls through when Praxis is not configured. + {name: "attention", answer: (*reactiveHandler).queryAttention}, // Before the recall sources too: "сколько я потратил?" is a question about // the money facts the poller wrote, and the notes pass would otherwise // answer it from whatever he once said about spending. Its matcher needs a diff --git a/cmd/mavend/attentionq.go b/cmd/mavend/attentionq.go new file mode 100644 index 0000000..bb5befa --- /dev/null +++ b/cmd/mavend/attentionq.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "strings" + + "github.com/kami/maven/internal/router" +) + +// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a +// stem, because "внимание", "внимания" and "вниманию" are one word to him. +// +// "что нового" is deliberately absent: the feeds source claims it, and it +// still should — a question about news is a question about the feeds she +// reads. This list is about the operational state of his things. +var attentionMarkers = []string{ + "внимани", "что требует", "что не так", "что важн", "что срочн", + "needs attention", "what needs looking", +} + +// isAttentionQuery reports whether the utterance asks what needs looking at. +func isAttentionQuery(u string) bool { + s := strings.ToLower(strings.TrimSpace(u)) + if s == "" { + return false + } + for _, m := range attentionMarkers { + if strings.Contains(s, m) { + return true + } + } + return false +} + +// queryAttention answers "что требует внимания?" from Praxis. +// +// The capability was already built and already degraded correctly, and no +// utterance could reach it (Vikunja #475). Its aliases live on the act +// dispatch, and the question routes to IntentQuery, so it fell through every +// source to the web search and came back with an encyclopedia article about +// the concept of attention — worse than silence, because it reads as an +// answer. +// +// Placed above the recall sources and well above the personal boundary: this +// is operational state about his things, and a notes pass would otherwise +// answer it from whatever he once wrote about a server. An unconfigured or +// absent Praxis falls through rather than claiming the turn, the same +// convention queryHome and queryNetwork follow. A Praxis that is configured +// and down does claim it, and says it cannot reach the service — that is the +// degradation the ecosystem contract asks for, and it comes from the same +// handler the act path uses. +func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) { + if !isAttentionQuery(t.dec.Utterance) { + return "", false + } + if h.ecosystem == nil || h.ecosystem.praxis == nil { + return "", false + } + reply := h.handlePraxisAct(ctx, router.Decision{ + Utterance: t.dec.Utterance, + Intent: router.IntentAct, + Slots: router.Slots{Fn: "list_attention", HasFn: true}, + }) + if reply == "" { + return "", false + } + return reply, true +} diff --git a/cmd/mavend/attentionq_test.go b/cmd/mavend/attentionq_test.go new file mode 100644 index 0000000..ee95411 --- /dev/null +++ b/cmd/mavend/attentionq_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +func TestIsAttentionQuery(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + {"что требует внимания", true}, + {"на что обратить внимание?", true}, + {"что не так?", true}, + {"что важного?", true}, + // The feeds source owns this one, and should keep owning it. + {"что нового?", false}, + {"какая погода?", false}, + {"", false}, + } { + if got := isAttentionQuery(tc.text); got != tc.want { + t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question +// routes to IntentQuery, and every source used to pass, so a web search about +// the concept of attention answered it. +func TestAttentionQuestionReachesPraxis(t *testing.T) { + ctx := context.Background() + praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{ + "id": "item_1", "title": "disk almost full", "importance": 3.0, + })) + h := ecoHandler(t, nil, praxis, nil) + + reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}) + if !ok { + t.Fatal("the attention question must be claimed before the world sources") + } + if !strings.Contains(reply, "disk almost full") { + t.Fatalf("reply = %q, want the praxis item", reply) + } +} + +// A configured Praxis that is down claims the turn and says so. Falling +// through here would answer an outage with an encyclopedia article. +func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) { + ctx := context.Background() + praxis := newFakePraxis(t, fixturePraxisAttentionItems()) + h := ecoHandler(t, nil, praxis, nil) + praxis.SetFault(503) + + reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}) + if !ok || !strings.Contains(reply, "не могу") { + t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply) + } +} + +// No Praxis configured means no claim: the rest of the chain still runs. +func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) { + h, _ := newFactGateHandler(t, time.Now()) + if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}); ok { + t.Fatal("an unconfigured praxis must not claim the turn") + } +}