diff --git a/cmd/mavend/attention_degraded_test.go b/cmd/mavend/attention_degraded_test.go new file mode 100644 index 0000000..c967a1e --- /dev/null +++ b/cmd/mavend/attention_degraded_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// An empty attention list used to be answered "ничего не требует внимания" +// unconditionally, which is an all-clear Maven had no way to know was true +// (ECOSYSTEM-SPEC §2.6, Vikunja #540). +func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) { + praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")) + if !strings.Contains(reply, "ничего не требует внимания") { + t.Fatalf("healthy and quiet should be an all-clear, got %q", reply) + } +} + +func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) { + praxis := newFakePraxisWithSources(t, `[]`, `[ + {"source_id":"src_ntfy","health":"ok"}, + {"source_id":"src_llamacpp","health":"failed"}, + {"source_id":"src_imap","health":"stale"} + ]`) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")) + if strings.Contains(reply, "ничего не требует внимания") { + t.Fatalf("a failed source must not read as all-clear, got %q", reply) + } + for _, want := range []string{"src_llamacpp", "src_imap"} { + if !strings.Contains(reply, want) { + t.Errorf("reply names no %s: %q", want, reply) + } + } + if strings.Contains(reply, "src_ntfy") { + t.Errorf("the healthy source is named as a problem: %q", reply) + } +} + +// A Praxis that polls nothing knows nothing, which is the state the box is in. +func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) { + praxis := newFakePraxisWithSources(t, `[]`, `[]`) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")) + if strings.Contains(reply, "ничего не требует внимания") { + t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply) + } + if !strings.Contains(reply, "источник") { + t.Errorf("reply does not say why she cannot tell: %q", reply) + } +} + +// The spec's own mechanism, which the deployed Praxis does not send yet: the +// envelope's degraded array is believed without a second call. +func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) { + praxis := newFakePraxisWithSources(t, + `{"items":[],"degraded":["src_metrics"]}`, + `[{"source_id":"src_ntfy","health":"ok"}]`) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")) + if !strings.Contains(reply, "src_metrics") { + t.Fatalf("the envelope's degraded source is not named: %q", reply) + } + for _, r := range praxis.Requests() { + if r.Path == "/api/v1/sources" { + t.Error("sources was read even though the response carried degraded") + } + } +} + +// A sources endpoint that errors is not evidence of a fault: the attention call +// itself succeeded, and hedging on it would make her permanently uncertain. +func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) { + praxis := newFakePraxisWithSources(t, `[]`, `[]`) + praxis.SetRouteFault("/api/v1/sources", 500) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")) + if !strings.Contains(reply, "ничего не требует внимания") { + t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply) + } +} + +// Both response shapes decode, because the spec says one and the box sends the +// other. +func TestPraxisAttentionDecodesBothShapes(t *testing.T) { + var bare praxisAttention + if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil { + t.Fatalf("bare array: %v", err) + } + if len(bare.Items) != 1 || len(bare.Degraded) != 0 { + t.Errorf("bare array decoded as %+v", bare) + } + + var env praxisAttention + if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil { + t.Fatalf("envelope: %v", err) + } + if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" { + t.Errorf("envelope decoded as %+v", env) + } +} + +// A source that reports no health at all counts as healthy. A Praxis that never +// fills the field would otherwise make every quiet turn a hedge. +func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) { + praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`) + bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background()) + if err != nil { + t.Fatalf("UnhealthySources: %v", err) + } + if total != 2 { + t.Errorf("total = %d, want 2", total) + } + if len(bad) != 1 || bad[0] != "src_b" { + t.Errorf("bad = %v, want [src_b]", bad) + } +} diff --git a/cmd/mavend/ecosystem.go b/cmd/mavend/ecosystem.go index bca3a5a..5d32f6b 100644 --- a/cmd/mavend/ecosystem.go +++ b/cmd/mavend/ecosystem.go @@ -263,18 +263,85 @@ func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) er return nil } -func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) { - var out []map[string]any +// praxisAttention — an attention response in either of the two shapes Praxis +// may send (Vikunja #540). +// +// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a +// source is failed or stale, and that Maven is required to say so rather than +// report all-clear. The deployed Praxis answers with a bare JSON array and no +// envelope at all, so both are decoded here: an array is the items, an object is +// the spec envelope. This lands the Maven half without waiting on the server, +// and the sources read below is what makes the hedge work meanwhile. +type praxisAttention struct { + Items []map[string]any + Degraded []string +} + +func (a *praxisAttention) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '[' { + return json.Unmarshal(trimmed, &a.Items) + } + var env struct { + Items []map[string]any `json:"items"` + Degraded []string `json:"degraded"` + } + if err := json.Unmarshal(trimmed, &env); err != nil { + return err + } + a.Items, a.Degraded = env.Items, env.Degraded + return nil +} + +func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) { + var out praxisAttention err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out) return out, err } +// praxisSource — one polled source, as much of it as the hedge needs. The tools +// API does not expose sources, so this decodes the plain `/api/v1/sources` rows. +type praxisSource struct { + ID string `json:"id"` + SourceID string `json:"source_id"` + Health string `json:"health"` +} + +func (s praxisSource) name() string { + if s.SourceID != "" { + return s.SourceID + } + return s.ID +} + +// UnhealthySources reports which sources cannot be trusted to have reported, +// and how many sources Praxis has at all (Vikunja #540). +// +// Only read when the attention list came back empty, which is the one turn where +// an all-clear is at stake. A source whose health field is absent counts as +// healthy: a Praxis that never reports health would otherwise make every quiet +// turn a hedge, and an unreported field is not evidence of a fault. Everything it +// does report other than "ok" — failed, stale, degraded, unknown — counts as +// cannot-tell, because none of them mean the source has spoken. +func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) { + var out []praxisSource + if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil { + return nil, 0, err + } + for _, s := range out { + if s.Health != "" && s.Health != "ok" { + bad = append(bad, s.name()) + } + } + return bad, len(out), nil +} + // ListAttentionForEntity is ListAttention scoped to a single canonical Nexus // entity, so callers already holding a resolved entity_id (e.g. after // resolveEntityReference) can ask "what needs attention for this entity" // instead of filtering the unscoped list client-side. -func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) { - var out []map[string]any +func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) { + var out praxisAttention err := c.getJSON(ctx, "attention_for_entity", fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out) return out, err diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 700867d..0277479 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -154,14 +154,18 @@ func (listAttentionCapability) aliases() []string { func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string { started := h.now() - items, err := px.ListAttention(ctx, 20) + att, err := px.ListAttention(ctx, 20) if err != nil { log.Printf("ecosystem: praxis attention: %v", err) h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err), started, traceErrorFields(err)) return phraser.A(phraser.AttentionFail, nil) } + items := att.Items if len(items) == 0 { + if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" { + return hedge + } return phraser.A(phraser.AttentionNone, nil) } h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)}) @@ -299,14 +303,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, } queried := h.now() - items, err := px.ListAttentionForEntity(ctx, entityID, 20) + att, err := px.ListAttentionForEntity(ctx, entityID, 20) if err != nil { log.Printf("ecosystem: praxis attention for %s: %v", entityID, err) h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err), queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName}) } - items, scoped := scopedToEntity(items, entityID) + items, scoped := scopedToEntity(att.Items, entityID) if !scoped { // A Praxis old enough to ignore an unknown query parameter answers the // scoped question with the unscoped list. Reading that back as "по @@ -339,6 +343,11 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, parts = append(parts, known) } if len(parts) == 0 { + // The scoped list is as exposed to a silent source as the unscoped one, + // and a per-entity all-clear is the more convincing of the two (#540). + if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" { + return hedge + } return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName}) } return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")}) @@ -783,3 +792,46 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec } return h.handleHexisAct(ctx, dec) } + +// attentionCannotTell returns the hedge to say instead of an all-clear, or "" +// when an empty attention list really does mean nothing needs looking at +// (ECOSYSTEM-SPEC §2.6, Vikunja #540). +// +// "Nothing needs attention" and "I cannot currently tell" are different answers +// and only one of them was ever said. The spec's mechanism is a `degraded` array +// on the attention response, which the deployed Praxis does not send, so the +// source health read is the half that works today. It costs one HTTP call and +// only on the empty-list turn, which is the only turn where an all-clear is at +// stake. +// +// A failed sources read is deliberately NOT a hedge. The attention call itself +// succeeded, and not being able to ask about health is not evidence of a fault — +// hedging on it would turn one flaky endpoint into a permanently uncertain +// assistant. +func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string { + if len(degraded) > 0 { + h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{ + "degraded": strings.Join(degraded, ","), "source": "response", + }) + return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")}) + } + bad, total, err := px.UnhealthySources(ctx) + if err != nil { + log.Printf("ecosystem: praxis sources: %v", err) + return "" + } + if total == 0 { + // A Praxis that polls nothing knows nothing, so its silence is not an + // all-clear either. This is the state the box is in as of 2026-08-05: + // /api/v1/sources answers with an empty array. + h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0}) + return phraser.A(phraser.AttentionNoSources, nil) + } + if len(bad) > 0 { + h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{ + "degraded": strings.Join(bad, ","), "sources": total, "source": "health", + }) + return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")}) + } + return "" +} diff --git a/cmd/mavend/fakeecosystem_test.go b/cmd/mavend/fakeecosystem_test.go index 707f6ab..cb34528 100644 --- a/cmd/mavend/fakeecosystem_test.go +++ b/cmd/mavend/fakeecosystem_test.go @@ -330,7 +330,17 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer { // Maven's praxisClient calls. Every route returns its fixed body until a // fault is injected via SetFault. func newFakePraxis(t *testing.T, attentionBody string) *fakeServer { + // One healthy source by default: an empty attention list only means + // all-clear when something is actually polling (Vikunja #540), and the + // other tests here are about attention rather than about source health. + return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`) +} + +// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body +// under the test's control, for the degraded and no-sources hedges. +func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer { return newFakeServer(t, map[string]http.HandlerFunc{ + "GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody), "GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody), "GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`), "POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`), diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index fd20b46..d3b85d7 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -60,13 +60,17 @@ const ( AttentionNoneEntity = "attention_none_entity" AttentionListEntity = "attention_list_entity" AttentionFailEntity = "attention_fail_entity" - ChangesNone = "changes_none" - ChangesList = "changes_list" - ChangesFail = "changes_fail" - HomeUnreachable = "home_unreachable" - HomeEmpty = "home_empty" - HomeOn = "home_on" - HomeDark = "home_dark" + // AttentionDegraded and AttentionNoSources — the two ways an empty + // attention list is not an all-clear (ECOSYSTEM-SPEC §2.6, Vikunja #540). + AttentionDegraded = "attention_degraded" + AttentionNoSources = "attention_no_sources" + ChangesNone = "changes_none" + ChangesList = "changes_list" + ChangesFail = "changes_fail" + HomeUnreachable = "home_unreachable" + HomeEmpty = "home_empty" + HomeOn = "home_on" + HomeDark = "home_dark" ) var actKeys = []string{ @@ -76,6 +80,7 @@ var actKeys = []string{ EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall, AttentionNone, AttentionList, AttentionFail, AttentionNoneEntity, AttentionListEntity, AttentionFailEntity, + AttentionDegraded, AttentionNoSources, ChangesNone, ChangesList, ChangesFail, HomeUnreachable, HomeEmpty, HomeOn, HomeDark, } @@ -113,6 +118,8 @@ var actFloor = map[string]string{ AttentionNoneEntity: "по «{name}» ничего нет.", AttentionListEntity: "по «{name}»: {items}", AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».", + AttentionDegraded: "за всё не отвечу — источники молчат: {items}.", + AttentionNoSources: "мне пока нечего смотреть — у Praxis нет источников.", ChangesNone: "изменений нет.", ChangesList: "изменения: {items}", ChangesFail: "не могу сейчас узнать об изменениях.", diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index 932b5e5..4d66e9e 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -114,6 +114,14 @@ "fixed": true, "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, + "attention_degraded": { + "fixed": true, + "variants": ["за всё не отвечу — источники молчат: {items}."] + }, + "attention_no_sources": { + "fixed": true, + "variants": ["мне пока нечего смотреть — у Praxis нет источников."] + }, "changes_none": { "fixed": true, "variants": ["изменений нет."]