Files
Maven/internal/phraser/nudge_templates_test.go
T
claude 23d89b2831 plural service_down nudges agree with the count (V-534)
Two services down read "Мониторинг сообщает: nginx, paperless лежит." — a list
dropped into the singular sentence. Russian agrees the verb with the subject,
so the noun, the verb and the adjective all have to move.

A family may now carry a second set named <rule>_many, used when {service}
holds more than one name. pluralFamily picks it; a family with no _many set is
returned unchanged, so adding one elsewhere is a data change. Only service_down
has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:17:30 +04:00

303 lines
9.3 KiB
Go

package phraser
import (
"context"
"math/rand"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/loop"
"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)
}
}
}
// Russian agrees the verb with the subject, so a list of services cannot go
// into the singular sentence. One down takes the singular set, two or more
// take service_down_many.
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
nt := newTestTemplates(t, 9)
// "упал " keeps its trailing space: "упали" starts with "упал", and the
// plural must not read as the singular by prefix.
singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
plural := []string{"не отвечают", "недоступны", "лежат", "упали"}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("paperless"))
if !containsAny(body, singular) {
t.Fatalf("one down, no singular verb: %q", body)
}
if containsAny(body, plural) {
t.Fatalf("one down, plural wording: %q", body)
}
}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("nginx", "paperless"))
if !containsAny(body, plural) {
t.Fatalf("two down, no plural verb: %q", body)
}
if containsAny(body, singular) {
t.Fatalf("two down, singular wording: %q", body)
}
}
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// 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)
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
if sinceMin > 0 || factKey != "" {
key := rule
if factKey != "" {
key = factKey
}
st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)}
}
return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st}
}
func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
t.Helper()
nt, err := NewNudgeTemplates(rand.NewSource(seed))
if err != nil {
t.Fatalf("NewNudgeTemplates: %v", err)
}
return nt
}
func TestNudgeTemplatesLoad(t *testing.T) {
nt := newTestTemplates(t, 1)
for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
set, ok := nt.file.Rules[rule]
if !ok {
t.Errorf("no templates for %q", rule)
continue
}
if len(set.Variants) < 5 {
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, 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 {
if !placeholderRE.MatchString(v) {
plain++
}
if seen[v] {
t.Errorf("%s: duplicate variant %q", rule, v)
}
seen[v] = true
}
if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
t.Errorf("%s: every variant needs a placeholder value", rule)
}
}
}
// The whole point of the picker: never the same words twice in a row.
func TestNudgeNoImmediateRepeat(t *testing.T) {
nt := newTestTemplates(t, 7)
prev := ""
for i := 0; i < 200; i++ {
body, _ := nt.Nudge(cand("water", 200, ""))
if body == prev {
t.Fatalf("repeat at %d: %q", i, body)
}
prev = body
}
}
// Same seed, same sequence — otherwise the fixture score would drift run to run.
func TestNudgeDeterministicWithSeed(t *testing.T) {
var runs [2][]string
for r := range runs {
nt := newTestTemplates(t, 42)
for i := 0; i < 20; i++ {
body, _ := nt.Nudge(cand("break", 100, ""))
runs[r] = append(runs[r], body)
}
}
for i := range runs[0] {
if runs[0][i] != runs[1][i] {
t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i])
}
}
}
// A variant is only used when its value exists, and nothing half-filled ships.
func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
nt := newTestTemplates(t, 3)
cases := []loop.Candidate{
cand("water", 0, ""), // no duration
cand("water", 30, ""), // under an hour
cand("water", 200, ""), // hours
downCand("vaultwarden"),
downCand(), // nothing down: the fallback answers
cand("routine:таблетки", 0, ""),
cand("morning:утро", 0, ""),
cand("unknown_rule", 0, ""),
}
for _, c := range cases {
for i := 0; i < 40; i++ {
body, mood := nt.Nudge(c)
if body == "" {
t.Fatalf("%s: empty body", c.Rule.Name)
}
if strings.ContainsAny(body, "{}%") {
t.Fatalf("%s: unfilled template %q", c.Rule.Name, body)
}
if mood != "neutral" {
t.Fatalf("%s: mood %q", c.Rule.Name, mood)
}
}
}
}
// The routine name must actually land in the text.
func TestNudgeSubstitutesWhat(t *testing.T) {
nt := newTestTemplates(t, 11)
for i := 0; i < 40; i++ {
body, _ := nt.Nudge(cand("routine:таблетки", 0, ""))
if !strings.Contains(strings.ToLower(body), "таблетки") {
t.Fatalf("routine text lost the name: %q", body)
}
}
}
func TestRuSinceWords(t *testing.T) {
cases := []struct {
min int
want string
}{
{30, ""},
{60, "час"},
{95, "полтора часа"},
{150, "два с половиной часа"},
{190, "три часа"},
{240, "четыре часа"},
{430, "семь часов"},
{660, "одиннадцать часов"},
{60 * 30, "больше суток"},
}
for _, c := range cases {
got := ruSinceWords(time.Duration(c.min) * time.Minute)
if got != c.want {
t.Errorf("%d min: got %q want %q", c.min, got, c.want)
}
}
}
// Templates are the default: a nudge must not reach the model at all.
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{})
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if len(spy.grammars) != 0 {
t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
}
if !strings.Contains(strings.ToLower(pn.Body), "вод") {
t.Errorf("nudge is not the water template: %q", pn.Body)
}
}
// ...and the flag brings the model back.
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if len(spy.grammars) != 1 {
t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
}
if pn.Body != "ага" {
t.Errorf("body = %q, want the model's reply", pn.Body)
}
}
func TestNudgeTemplatesPhraseNudge(t *testing.T) {
nt := newTestTemplates(t, 5)
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" {
t.Fatalf("bad nudge: %+v", pn)
}
}