23d89b2831
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
289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
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:<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 "сорок минут". 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)
|
|
}
|