From 23d89b2831fa3f411a1e2604e0a9bfa61724450d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 23:17:30 +0400 Subject: [PATCH] plural service_down nudges agree with the count (V-534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 _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 Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot --- internal/phraser/nudge_templates.go | 21 +++++++++++- internal/phraser/nudge_templates_test.go | 43 ++++++++++++++++++++++-- internal/phraser/nudges_ru_v1.json | 16 ++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/internal/phraser/nudge_templates.go b/internal/phraser/nudge_templates.go index d2e64a2..f205dd2 100644 --- a/internal/phraser/nudge_templates.go +++ b/internal/phraser/nudge_templates.go @@ -94,7 +94,7 @@ func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (deliv // template fits it uses the plain per-rule fallback. func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) { rule := c.Rule.Name - family := t.family(rule) + family := t.pluralFamily(t.family(rule), c) set, ok := t.file.Rules[family] if !ok { return fallbackNudge(c), "neutral" @@ -155,6 +155,25 @@ func (t *NudgeTemplates) family(rule string) string { return "default" } +// pluralFamily swaps in the plural wording when {service} will hold a list. +// Russian agrees the verb with the subject, so one set of templates cannot +// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не +// отвечают" differ in the noun, the verb and the adjective. Filling a list into +// the singular text is the kind of near-miss that reads as machine-written. +// +// Only service_down has a plural form today. A family with no "_many" set in +// the file is returned unchanged, so adding one is a data change. +func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string { + if len(loop.DownServices(c.State)) < 2 { + return family + } + many := family + "_many" + if _, ok := t.file.Rules[many]; ok { + return many + } + return family +} + // placeholderRE — the {name} slots a template may use. var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`) diff --git a/internal/phraser/nudge_templates_test.go b/internal/phraser/nudge_templates_test.go index fe0cb87..90458cc 100644 --- a/internal/phraser/nudge_templates_test.go +++ b/internal/phraser/nudge_templates_test.go @@ -55,6 +55,45 @@ func TestNudgeNamesTheDownService(t *testing.T) { } } +// 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) { @@ -93,7 +132,7 @@ func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates { func TestNudgeTemplatesLoad(t *testing.T) { nt := newTestTemplates(t, 1) - for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} { + 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) @@ -120,7 +159,7 @@ func TestNudgeTemplatesLoad(t *testing.T) { } seen[v] = true } - if plain == 0 && rule != "routine" && rule != "morning" && rule != "service_down" { + if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") { t.Errorf("%s: every variant needs a placeholder value", rule) } } diff --git a/internal/phraser/nudges_ru_v1.json b/internal/phraser/nudges_ru_v1.json index d10f8d3..fa7aa7f 100644 --- a/internal/phraser/nudges_ru_v1.json +++ b/internal/phraser/nudges_ru_v1.json @@ -5,7 +5,8 @@ "Hand-written Russian nudges. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.", "One short sentence. No questions, no emoji, no pet names, no emotional support.", - "Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.", + "Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine, morning and service_down: those only exist for rules that always carry a name, and one that drops the name is useless.", + "A rule may carry a second set named _many, used when {service} holds more than one name. Russian agrees the verb with the subject, so the plural needs its own wording rather than a list dropped into the singular sentence. Only service_down has one.", "mood must be one of: neutral, happy, thinking, tired, confused." ], "rules": { @@ -67,6 +68,19 @@ "Сервис {service} не отвечает, посмотри логи." ] }, + "service_down_many": { + "mood": "neutral", + "variants": [ + "Сервисы {service} не отвечают.", + "{service} упали — сервисы не отвечают.", + "{service} не отвечают, сервисы нужно поднимать.", + "Сервисы {service} недоступны.", + "Проверь {service}: сервисы не отвечают.", + "Сервисы {service} лежат, нужно смотреть.", + "Мониторинг сообщает: {service} лежат.", + "Сервисы {service} не отвечают, посмотри логи." + ] + }, "netdata_critical": { "mood": "neutral", "variants": [