diff --git a/internal/phraser/eval/templates_test.go b/internal/phraser/eval/templates_test.go new file mode 100644 index 0000000..523ee65 --- /dev/null +++ b/internal/phraser/eval/templates_test.go @@ -0,0 +1,58 @@ +package eval + +import ( + "context" + "math/rand" + "testing" + + "github.com/kami/maven/internal/phraser" +) + +// TestTemplateNudges scores the hand-written Russian templates on the same +// fixture the model is scored on. No model, no network — it runs in milliseconds. +// +// The bar is every case, not most of them: the templates are hand-written, so a +// failure is a bug in one line of Russian, not model variance. +func TestTemplateNudges(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + // Fixed seed: the score must not depend on which variant came up. + nt, err := phraser.NewNudgeTemplates(rand.NewSource(20260731)) + if err != nil { + t.Fatalf("NewNudgeTemplates: %v", err) + } + rep, err := Score(context.Background(), "ru templates", nt, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String()) + t.Log("\n" + rep.Messages()) + if rep.Passed != rep.Total { + t.Errorf("templates scored %d/%d, want every case:\n%s", + rep.Passed, rep.Total, rep.Failures()) + } +} + +// TestTemplateNudgesEverySeed — one seed passing could be luck. Every variant of +// every rule has to pass every check, so sweep seeds until each has been used. +func TestTemplateNudgesEverySeed(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + for seed := int64(0); seed < 60; seed++ { + nt, err := phraser.NewNudgeTemplates(rand.NewSource(seed)) + if err != nil { + t.Fatalf("NewNudgeTemplates: %v", err) + } + rep, err := Score(context.Background(), "ru templates", nt, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + if rep.Passed != rep.Total { + t.Errorf("seed %d: %d/%d\n%s", seed, rep.Passed, rep.Total, rep.Failures()) + } + } +} diff --git a/internal/phraser/nudge_templates.go b/internal/phraser/nudge_templates.go new file mode 100644 index 0000000..bb9027f --- /dev/null +++ b/internal/phraser/nudge_templates.go @@ -0,0 +1,261 @@ +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" + "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.family(rule) + 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" +} + +// 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 + + // {since} — only at hour scale. Below an hour the phrase would be minutes, + // and none of the templates read well with "сорок минут". + 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:] + } + 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. +var hourWords = []string{ + "ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", + "девять", "десять", "одиннадцать", "двенадцать", "тринадцать", + "четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", + "девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три", +} + +// 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 >= len(hourWords) { + return "больше суток" + } + if h == 1 { + if m >= 15 { + return "полтора часа" + } + return "час" + } + if m >= 15 { + return hourWords[h] + " с половиной часа" + } + return hourWords[h] + " " + hourPlural(h) +} diff --git a/internal/phraser/nudge_templates_test.go b/internal/phraser/nudge_templates_test.go new file mode 100644 index 0000000..a12f10b --- /dev/null +++ b/internal/phraser/nudge_templates_test.go @@ -0,0 +1,170 @@ +package phraser + +import ( + "context" + "math/rand" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +// 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", "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 and morning are exempt: + // they always carry a name and must always say it. + 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" { + 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 + cand("service_down", 3, "vaultwarden"), + cand("service_down", 3, ""), // no service name + 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) + } + } +} + +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) + } +} diff --git a/internal/phraser/nudges_ru_v1.json b/internal/phraser/nudges_ru_v1.json new file mode 100644 index 0000000..cb2201b --- /dev/null +++ b/internal/phraser/nudges_ru_v1.json @@ -0,0 +1,129 @@ +{ + "schema_version": 1, + "name": "russian nudge templates v1", + "notes": [ + "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.", + "mood must be one of: neutral, happy, thinking, tired, confused." + ], + "rules": { + "water": { + "mood": "neutral", + "variants": [ + "Ты не пил воду {since} — выпей стакан.", + "Пора выпить воды.", + "Стакан воды не помешает.", + "Воду ты не пил уже {since}.", + "Напоминаю про воду.", + "Сходи за водой, дела подождут.", + "Сделай глоток воды, пока помнишь.", + "Между делом выпей воды.", + "Вода — простое дело: выпей стакан.", + "Отвлекись на стакан воды." + ] + }, + "meal": { + "mood": "neutral", + "variants": [ + "Ты не ел {since} — поешь.", + "Пора поесть, сделай перекус.", + "Еда важнее ещё одного часа за столом.", + "Без еды уже {since}, поешь.", + "Напоминаю про еду — поешь.", + "Возьми перерыв на обед.", + "Сделай себе перекус, это пять минут.", + "Поешь, потом вернёшься к работе.", + "Поешь нормально, а не на ходу.", + "Еды не было {since} — разогрей что-нибудь." + ] + }, + "break": { + "mood": "neutral", + "variants": [ + "Ты за столом {since} — встань и разомнись.", + "Пора сделать перерыв.", + "Встань на пять минут.", + "{since} без перерыва — отойди от экрана.", + "Напоминаю про перерыв.", + "Разомни спину, потом продолжишь.", + "Короткая пауза не сорвёт дела.", + "Отойди от компьютера на минуту.", + "Сидишь без перерыва {since}.", + "Встань, пройдись, вернись." + ] + }, + "service_down": { + "mood": "neutral", + "variants": [ + "Сервис {service} не отвечает.", + "{service} упал — сервис не отвечает.", + "{service} не отвечает, сервис нужно поднимать.", + "Сервис {service} недоступен.", + "Проверь {service}: сервис не отвечает.", + "Сервис перестал отвечать.", + "Сервис {service} лежит, нужно смотреть.", + "{service} не отвечает уже {since}.", + "Мониторинг сообщает: {service} лежит.", + "Сервис {service} не отвечает, посмотри логи." + ] + }, + "netdata_critical": { + "mood": "neutral", + "variants": [ + "Netdata: критический алярм, проверь диск.", + "Критический алярм в netdata — посмотри диск.", + "Netdata поднял тревогу по диску.", + "Проверь диск: netdata ругается.", + "Алярм от netdata, критический.", + "Netdata: критический уровень, дело в диске.", + "Диск требует внимания — критический алярм в netdata.", + "Критический алярм: проверь место на диске.", + "Netdata сообщает о критической проблеме с диском.", + "Открой netdata: там критический алярм по диску." + ] + }, + "routine": { + "mood": "neutral", + "variants": [ + "По распорядку: {what}.", + "Пора — {what}.", + "Напоминаю: {what}.", + "В списке на сейчас: {what}.", + "{what} — сейчас самое время.", + "Не пропусти: {what}.", + "{what}: пора сделать.", + "Сейчас по плану {what}.", + "Твой распорядок: {what}.", + "{what} — по распорядку сейчас." + ] + }, + "morning": { + "mood": "neutral", + "variants": [ + "{what} — пора начать день.", + "{what}: пройди утренний список.", + "Начни {what} со списка.", + "{what}. Осталось пройти чеклист.", + "Утренний список ещё не пройден: {what}.", + "{what}: первый пункт списка за тобой.", + "{what} идёт, а список стоит.", + "{what}: не забудь про утренние дела.", + "По утреннему чеклисту ещё есть дела: {what}.", + "{what} — утренний список дел ещё ждёт." + ] + }, + "default": { + "mood": "neutral", + "variants": [ + "Напоминаю: есть дело.", + "Пора вернуться к отложенному делу.", + "Одно дело ждёт тебя.", + "Напоминаю про дело из списка.", + "В списке осталось дело.", + "Дело всё ещё не сделано." + ] + } + } +}