13e5170e9e
Nudge wording as data instead of generation. The wording lives in
internal/phraser/nudges_ru_v1.json (embedded), about 10 variants per rule:
water, meal, break, service_down, netdata_critical, routine:, morning:, plus
a contentless default. That JSON is long because it is data — the owner can
edit any line of Russian without touching Go.
The picker:
- random, but never the same variant twice in a row for the same rule
- deterministic when seeded (math/rand with an injectable source)
- fills {since} / {service} / {what} from the candidate, and skips any variant
whose value is missing, so no raw placeholder can reach the piper voice
- {since} is spelled out in words ("полтора часа", "семь часов"), because
"3 ч" is wrong in a Russian voice
Scores 15/15 on the existing nudge fixture, on every seed swept. Nothing is
wired yet — that is the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
262 lines
7.8 KiB
Go
262 lines
7.8 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"
|
|
"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)
|
|
}
|