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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
@@ -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:<name>", 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:]
|
||||
|
||||
@@ -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, ""),
|
||||
|
||||
@@ -62,9 +62,7 @@
|
||||
"{service} не отвечает, сервис нужно поднимать.",
|
||||
"Сервис {service} недоступен.",
|
||||
"Проверь {service}: сервис не отвечает.",
|
||||
"Сервис перестал отвечать.",
|
||||
"Сервис {service} лежит, нужно смотреть.",
|
||||
"{service} не отвечает уже {since}.",
|
||||
"Мониторинг сообщает: {service} лежит.",
|
||||
"Сервис {service} не отвечает, посмотри логи."
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user