From 06ddf412286cc2cfeeb47dd1aa70dd26fd99e7a9 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 23:13:59 +0400 Subject: [PATCH] service_down nudges name the service again (V-534) nudgeValues filled {service} from State.Fact("service_down"), an exact key mavpoll stopped writing when per-monitor facts landed. The lookup could never hit, so every variant carrying {service} was rejected as unfillable and the one nameless variant was the only usable template, every time. A sev4 reaching him on telegram said only that a service was down. It now reads loop.DownServices, the same helper the rule fires on, so the message cannot name a service that is up. Dropped the nameless variant and the {since} one: service_down facts are keyed by monitor and the rule is edge-triggered, so neither can fill. service_down joins routine and morning as a family that always carries a name. The tests passed through all of this because cand() built the pre-per-monitor aggregate shape. downCand() builds what a tick actually produces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot --- internal/phraser/nudge_templates.go | 18 ++++-- internal/phraser/nudge_templates_test.go | 71 ++++++++++++++++++++++-- internal/phraser/nudges_ru_v1.json | 2 - 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/internal/phraser/nudge_templates.go b/internal/phraser/nudge_templates.go index 511810e..d2e64a2 100644 --- a/internal/phraser/nudge_templates.go +++ b/internal/phraser/nudge_templates.go @@ -165,17 +165,25 @@ func nudgeValues(c loop.Candidate) map[string]string { vals := map[string]string{} rule := c.Rule.Name + // {service} — one fact per kuma monitor, keyed "service_down:", so + // the name lives in the key SUFFIX and there is no fact called plain + // "service_down" to read. loop.DownServices is the same helper the rule + // fired on, which is what stops the message naming a service that is up. + // This used to read c.State.Fact(rule) — the pre-per-monitor aggregate — + // and so never filled, leaving the one nameless variant as the only + // fillable template every time (Vikunja #534). + if down := loop.DownServices(c.State); len(down) > 0 { + vals["service"] = strings.Join(down, ", ") + } // {since} — only at hour scale. Below an hour the phrase would be minutes, - // and none of the templates read well with "сорок минут". + // and none of the templates read well with "сорок минут". service_down has + // no {since} to offer: its facts are keyed by monitor, and the rule is + // edge-triggered, so it fires on the transition rather than hours later. if d, ok := c.State.Since(rule); ok && d >= time.Hour { if s := ruSinceWords(d); s != "" { vals["since"] = s } } - // {service} — the aggregate fact's key carries the service name. - if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule { - vals["service"] = f.Key - } // {what} — the Russian suffix of "routine:таблетки" / "morning:утро". if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { vals["what"] = rule[i+1:] diff --git a/internal/phraser/nudge_templates_test.go b/internal/phraser/nudge_templates_test.go index c33bd6a..fe0cb87 100644 --- a/internal/phraser/nudge_templates_test.go +++ b/internal/phraser/nudge_templates_test.go @@ -11,6 +11,63 @@ import ( "github.com/kami/maven/internal/store" ) +// downCand builds a service_down candidate the way a tick actually does it: +// one fact per kuma monitor under the prefix, carrying the source and value +// loop.DownServices checks. The old cand() shape wrote a single fact keyed +// plain "service_down", which mavpoll stopped producing, and that is why the +// tests passed through the whole of #534. +func downCand(names ...string) loop.Candidate { + now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC) + st := loop.State{Now: now, Facts: map[string]store.Fact{}} + for _, n := range names { + key := loop.ServiceDownPrefix + n + st.Facts[key] = store.Fact{ + Key: key, Ts: now.Add(-3 * time.Minute), + Source: loop.ServiceDownSource, Value: `"down"`, + } + } + return loop.Candidate{ + Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4}, + Severity: loop.Sev4, State: st, + } +} + +// The nudge he reads on telegram must name what broke. It is a sev4 that +// reaches him away from the box, so "a service is down" costs him a trip to +// kuma to learn anything at all. +func TestNudgeNamesTheDownService(t *testing.T) { + // Lowercased before matching: a name that opens the sentence is + // capitalized by capitalizeFirst, which is wanted. + nt := newTestTemplates(t, 5) + for i := 0; i < 40; i++ { + body, _ := nt.Nudge(downCand("paperless")) + if !strings.Contains(strings.ToLower(body), "paperless") { + t.Fatalf("body does not name the service: %q", body) + } + } + // Two down: both named, in the key order the rule itself uses. + for i := 0; i < 40; i++ { + body, _ := nt.Nudge(downCand("nginx", "paperless")) + low := strings.ToLower(body) + if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") { + t.Fatalf("body drops a service: %q", body) + } + } +} + +// Nothing down means no template fits, and the fallback answers rather than +// the picker inventing a name. +func TestNudgeServiceDownWithoutFacts(t *testing.T) { + nt := newTestTemplates(t, 5) + body, mood := nt.Nudge(downCand()) + if body != "Сервис не отвечает." { + t.Fatalf("fallback body %q", body) + } + if mood != "neutral" { + t.Fatalf("mood %q", mood) + } +} + // cand builds a candidate the way a tick would. func cand(rule string, sinceMin int, factKey string) loop.Candidate { now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC) @@ -46,8 +103,12 @@ func TestNudgeTemplatesLoad(t *testing.T) { t.Errorf("%s: only %d variants", rule, len(set.Variants)) } // Every rule needs one variant that needs no value, or a candidate - // without context has nothing to say. routine and morning are exempt: - // they always carry a name and must always say it. + // without context has nothing to say. routine, morning and + // service_down are exempt: they always carry a name and must always + // say it. service_down's predicate cannot fire without a down fact, + // so loop.DownServices always has something to fill {service} with, + // and the nameless variant it used to carry was the bug (#534) — + // {service} never filled, so that variant was the only fillable one. plain := 0 seen := map[string]bool{} for _, v := range set.Variants { @@ -59,7 +120,7 @@ func TestNudgeTemplatesLoad(t *testing.T) { } seen[v] = true } - if plain == 0 && rule != "routine" && rule != "morning" { + if plain == 0 && rule != "routine" && rule != "morning" && rule != "service_down" { t.Errorf("%s: every variant needs a placeholder value", rule) } } @@ -102,8 +163,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) { cand("water", 0, ""), // no duration cand("water", 30, ""), // under an hour cand("water", 200, ""), // hours - cand("service_down", 3, "vaultwarden"), - cand("service_down", 3, ""), // no service name + downCand("vaultwarden"), + downCand(), // nothing down: the fallback answers cand("routine:таблетки", 0, ""), cand("morning:утро", 0, ""), cand("unknown_rule", 0, ""), diff --git a/internal/phraser/nudges_ru_v1.json b/internal/phraser/nudges_ru_v1.json index cb2201b..d10f8d3 100644 --- a/internal/phraser/nudges_ru_v1.json +++ b/internal/phraser/nudges_ru_v1.json @@ -62,9 +62,7 @@ "{service} не отвечает, сервис нужно поднимать.", "Сервис {service} недоступен.", "Проверь {service}: сервис не отвечает.", - "Сервис перестал отвечать.", "Сервис {service} лежит, нужно смотреть.", - "{service} не отвечает уже {since}.", "Мониторинг сообщает: {service} лежит.", "Сервис {service} не отвечает, посмотри логи." ]