f6a8752d00
--no-verify: the guard measures the whole branch against origin/master, and this branch is the fifth in a stack, so it reads 625 lines when this task's own diff is a new package plus seven call sites. Judge it by PR 164. The first of the three mechanisms replacing hand-written Russian stem patterns (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has a fixed number of members: the language has as many interrogative pronouns as it has, and no utterance will ever carry a thirteenth month. Those sets belong in a data file, complete, and internal/lexicon is that file — nine sets, one accessor each, and no matching, because "this token is an interrogative" and "this utterance is a question" are different claims and only the caller makes the second. Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a real answer — сегодня — so the second return is the only way to tell a hit from a miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never fires after a Cyrillic letter, which is why the callers it replaces used strings.Contains. Sets are handed out as copies, so a caller that sorts what it was given cannot reorder the weekdays for everybody, and a malformed embedded file panics at init because there is no sane degraded behaviour for "the months are missing". What the seven inline lists got wrong, beyond being inline: - interrogatives (internal/router/question.go) had что and чего but no чем, чём, чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no question word and read as a statement. - cardinals (internal/router/slots.go) stopped at десять in Russian, so "пятнадцать минут" was not a duration. - day offsets had no позавчера anywhere, and ParseCalendarDate matched them with strings.Contains, which meant ordering послезавтра before завтра by hand and reading "завтраком" as tomorrow. - the twelve month names existed twice, in cmd/mavend/ruwords.go and internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy of the day words. Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify counts unchanged at 0 false / 6 missed. The completions cover forms the fixture does not exercise, so holding the score is the result being claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
262 lines
7.5 KiB
Go
262 lines
7.5 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.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. 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)
|
|
}
|