package phraser // Hand-written Russian nudges instead of generated ones. // // Why: on a nudge there is nothing to be creative about. Measured over many // runs, Qwen3.5-0.8B breaks the persona (formal "вы", plural imperatives, // masculine self-reference) and invents facts and units — it once told him to // boil an egg for "90-95 секунд". A nudge is five words of known content, so // wording it with a model buys nothing and risks the persona every time. // // The wording lives in nudges_ru_v1.json so it can be edited without touching // Go. This file only picks one and fills in the values. import ( "context" _ "embed" "encoding/json" "fmt" "math/rand" "regexp" "github.com/kami/maven/internal/lexicon" "strings" "sync" "time" "unicode" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" ) //go:embed nudges_ru_v1.json var nudgeTemplateJSON []byte // NudgeTemplateSchemaVersion — the version this code understands. const NudgeTemplateSchemaVersion = 1 type nudgeRuleSet struct { Mood string `json:"mood"` Variants []string `json:"variants"` } type nudgeTemplateFile struct { SchemaVersion int `json:"schema_version"` Name string `json:"name"` Notes []string `json:"notes"` Rules map[string]nudgeRuleSet `json:"rules"` } // NudgeTemplates picks a hand-written Russian nudge for a candidate. // // Safe for concurrent use. Random, but never the same variant twice in a row // for the same rule — being nagged with identical words is what makes a nudge // easy to tune out. type NudgeTemplates struct { mu sync.Mutex rnd *rand.Rand last map[string]string // rule family -> the text used last time file nudgeTemplateFile } // NewNudgeTemplates loads the embedded template file. Pass a source to make the // picking reproducible in tests; nil means seed from the clock. func NewNudgeTemplates(src rand.Source) (*NudgeTemplates, error) { var f nudgeTemplateFile if err := json.Unmarshal(nudgeTemplateJSON, &f); err != nil { return nil, fmt.Errorf("nudge templates: parse: %w", err) } if f.SchemaVersion != NudgeTemplateSchemaVersion { return nil, fmt.Errorf("nudge templates: schema_version %d, want %d", f.SchemaVersion, NudgeTemplateSchemaVersion) } if len(f.Rules) == 0 { return nil, fmt.Errorf("nudge templates: no rules") } if src == nil { src = rand.NewSource(time.Now().UnixNano()) } return &NudgeTemplates{ rnd: rand.New(src), last: map[string]string{}, file: f, }, nil } // PhraseNudge implements the nudge half of the Phraser interface, so the // templates can be scored by the same harness as the model. func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) { body, mood := t.Nudge(c) return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil } // Nudge returns the text and the mood for one candidate. Never fails: if no // 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.pluralFamily(t.family(rule), c) set, ok := t.file.Rules[family] if !ok { return fallbackNudge(c), "neutral" } vals := nudgeValues(c) // Only variants whose placeholders all have a value. usable := make([]string, 0, len(set.Variants)) for _, v := range set.Variants { if text, ok := fillTemplate(v, vals); ok { usable = append(usable, text) } } if len(usable) == 0 { return fallbackNudge(c), "neutral" } mood = set.Mood if mood == "" { mood = "neutral" } return t.pick(family, usable), mood } // pick chooses at random, skipping whatever this rule said last time. func (t *NudgeTemplates) pick(family string, usable []string) string { t.mu.Lock() defer t.mu.Unlock() choices := usable if len(usable) > 1 { choices = make([]string, 0, len(usable)) for _, v := range usable { if v != t.last[family] { choices = append(choices, v) } } if len(choices) == 0 { // every variant equals the last one choices = usable } } got := choices[t.rnd.Intn(len(choices))] t.last[family] = got return got } // family maps a rule name to a block in the template file: an exact match // first, then the prefix of "routine:зарядка" / "morning:утро", then "default". func (t *NudgeTemplates) family(rule string) string { if _, ok := t.file.Rules[rule]; ok { return rule } if i := strings.IndexByte(rule, ':'); i > 0 { if _, ok := t.file.Rules[rule[:i]]; ok { return rule[:i] } } 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]+)\}`) // nudgeValues collects what this candidate can fill in. A key missing here // means every template needing it is skipped, so nothing half-filled is ever // spoken. 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 "сорок минут". 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 } } // {what} — the Russian suffix of "routine:таблетки" / "morning:утро". if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { vals["what"] = rule[i+1:] } return vals } // fillTemplate substitutes the placeholders. Returns false when a value is // missing, so a raw "{since}" can never reach the text-to-speech voice. func fillTemplate(tmpl string, vals map[string]string) (string, bool) { missing := false out := placeholderRE.ReplaceAllStringFunc(tmpl, func(m string) string { name := m[1 : len(m)-1] v, ok := vals[name] if !ok || v == "" { missing = true return m } return v }) if missing || strings.ContainsAny(out, "{}%") { return "", false } return capitalizeFirst(out), true } // capitalizeFirst — a placeholder can start the sentence, and "полтора часа без // перерыва" should be spoken as a sentence, not a fragment. func capitalizeFirst(s string) string { for i, r := range s { return string(unicode.ToUpper(r)) + s[i+len(string(r)):] } return s } // hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a // Russian voice, so the number goes out as words. Closed set, indexed by the // hour, kept in internal/lexicon (Vikunja #525). func hourWord(h int) string { return lexicon.HourSpoken(h) } const hoursSpoken = 24 // hourPlural — час / часа / часов by Russian counting rules. func hourPlural(h int) string { if h%100 >= 11 && h%100 <= 14 { return "часов" } switch h % 10 { case 1: return "час" case 2, 3, 4: return "часа" default: return "часов" } } // ruSinceWords — "полтора часа", "два с половиной часа", "семь часов". // Empty string means "do not say it" (under an hour, or over a day). func ruSinceWords(d time.Duration) string { if d < time.Hour { return "" } h := int(d.Hours()) m := int(d.Minutes()) % 60 if m >= 45 { h++ m = 0 } if h >= hoursSpoken { return "больше суток" } if h == 1 { if m >= 15 { return "полтора часа" } return "час" } if m >= 15 { return hourWord(h) + " с половиной часа" } return hourWord(h) + " " + hourPlural(h) }