Compare commits

...

4 Commits

Author SHA1 Message Date
kami 6b67e6f3c2 Word nudges from templates by default, model optional
DEPRECATION, flagged not asked: LLM-phrased nudges are no longer the default.
LLMPhraser.PhraseNudge now returns a hand-written Russian template. The model
still phrases chat, queries and reminders — only nudges moved.

Why: measured over many runs, Qwen3.5-0.8B wrote formal "вы" and plural
imperatives, used masculine self-reference, and invented facts and units
(90-95 seconds to boil an egg). A nudge is five words of known content, so
generation buys nothing and risks the persona every time. Templates score
15/15 on the nudge fixture, the model 11-13/15.

Nothing is deleted: the prompt, the fallbacks and the whole LLM nudge path
stay. Set phraser.llm_nudges = true in deploy/mavend.json to get them back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 18:21:52 +04:00
kami 13e5170e9e Hand-written Russian nudge templates plus a picker
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
2026-07-31 18:18:55 +04:00
kami aa8f5b2ee2 Make the nonempty check look for actual words
It scored 27/27 on a run where two replies were "{" and "{\n  \"". It only
tested that the string was not blank, so punctuation counted as content and
the worst replies of the run passed the first check.

Now a reply needs at least one letter, Cyrillic or Latin. Latin counts
because answers about ssd or vpn are legitimately part English.

Digits alone fail too. The same run answered "сколько варить яйцо
вкрутую?" with "15-16" — no unit, no words, and the wrong number as well.
That is not something she said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 17:57:39 +04:00
kami d7cdcb63bd Stop shipping half-written JSON as a reply
Two bugs, one symptom. A run of the talk eval produced replies that were
literally "{" and "{\n  \"" — those strings went out as things Maven said.

First bug: the parser could not tell "the model answered in plain prose"
from "the model started a JSON object and got cut off". Both came back as
empty, and every caller then shipped the raw text. Now an unfinished object
returns an error and each caller uses its own fallback instead. Bare prose
with no JSON in it still passes through, because small models do sometimes
answer that way and the reply is fine.

Second bug, and the actual cause: the grammar capped the response field at
400 characters. I measured it against Qwen3.5-0.8B at three different token
caps — 256, 768 and 2048 — and the reply came back exactly 400 characters
every time, cut mid-word. So the token limit was never what stopped it.
The bound is 1000 now, about six Russian sentences, still low enough to cut
off a repetition loop.

Token caps go from 256 to 768 on the chat and query paths so 1000
characters of Russian actually fits. The nudge path keeps its own cap; a
nudge is meant to be one sentence.

Note: cmd/mavend/replier_llm.go has its own copy of this parser with the
same bug. Left alone here so this commit stays small — that duplicate is
Vikunja #396.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 17:56:55 +04:00
13 changed files with 890 additions and 22 deletions
+2
View File
@@ -278,6 +278,7 @@ func run(args []string) error {
NGpuLayers: cfg.Phraser.NGpuLayers,
NCtx: cfg.Phraser.NCtx,
Timeout: time.Duration(cfg.Phraser.Timeout),
LLMNudges: cfg.Phraser.LLMNudges,
ContextBlock: contextBlockFn(cfg, time.Now),
}
if pc.BinPath == "" {
@@ -448,6 +449,7 @@ func run(args []string) error {
NGpuLayers: cfg.Phraser.NGpuLayers,
NCtx: cfg.Phraser.NCtx,
Timeout: time.Duration(cfg.Phraser.Timeout),
LLMNudges: cfg.Phraser.LLMNudges,
ContextBlock: contextBlockFn(cfg, time.Now),
}
if pc.BinPath == "" {
+2 -1
View File
@@ -10,7 +10,8 @@
"bin_path": "llama-server",
"n_gpu_layers": 99,
"n_ctx": 2048,
"timeout": "60s"
"timeout": "60s",
"llm_nudges": false
},
"telegram": {
+6
View File
@@ -369,6 +369,12 @@ type PhraserConfig struct {
NGpuLayers int `json:"n_gpu_layers,omitempty"`
NCtx int `json:"n_ctx,omitempty"`
Timeout Duration `json:"timeout,omitempty"`
// LLMNudges — let the model word nudges again. Off by default: nudges are
// worded from hand-written Russian templates now (the model broke the
// persona and invented units). Chat, query and reminder phrasing always go
// through the model regardless. See phraser.Config.LLMNudges.
LLMNudges bool `json:"llm_nudges,omitempty"`
}
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
+21
View File
@@ -35,6 +35,27 @@ func TestLoadDefaults(t *testing.T) {
}
}
// Nudges come from templates unless the config says otherwise.
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Phraser.LLMNudges {
t.Error("llm_nudges defaults on; templates must be the default")
}
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
c, err = Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Phraser.LLMNudges {
t.Error("llm_nudges:true did not parse")
}
}
func TestLoadDurationsParse(t *testing.T) {
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
c, err := Load(p)
+54
View File
@@ -0,0 +1,54 @@
package phraser
import (
"errors"
"strings"
"testing"
)
// A reply that starts a JSON object and never finishes it is a failed
// generation, not a reply. Before this, the parser returned ("", "") for these
// and every caller then shipped the raw fragment as the thing Maven said. A
// real run produced replies of literally "{" and "{\n \"".
func TestParseResponseMoodRejectsUnfinishedJSON(t *testing.T) {
for _, raw := range []string{
`{`,
"{\n \"",
`{"response": "неполн`,
`{"response": "текст", "mood":`,
} {
text, mood, err := parseResponseMood(raw)
if !errors.Is(err, errBrokenJSON) {
t.Errorf("parseResponseMood(%q) err = %v, want errBrokenJSON", raw, err)
}
if text != "" || mood != "" {
t.Errorf("parseResponseMood(%q) leaked %q/%q — a fragment must never come back as a reply", raw, text, mood)
}
}
}
// Bare prose is still fine. Small models sometimes answer without any JSON at
// all, and that reply is usable — so the new error must not swallow it.
func TestParseResponseMoodAllowsBareProse(t *testing.T) {
for _, raw := range []string{
"норм, а ты как?",
"вот что я нашла: ключ у соседа",
} {
text, mood, err := parseResponseMood(raw)
if err != nil {
t.Errorf("parseResponseMood(%q) err = %v, want nil", raw, err)
}
// No JSON means no fields; the caller ships raw as-is.
if text != "" || mood != "" {
t.Errorf("parseResponseMood(%q) = %q/%q, want empty", raw, text, mood)
}
}
}
// The measured failure: the model wants more than 400 characters and the old
// grammar cut it off mid-word. Guards the bound against being tightened back.
func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
if !strings.Contains(responseGrammar, "{0,1000}") {
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
}
}
@@ -31,3 +31,35 @@ func TestAddressDeduplicates(t *testing.T) {
t.Errorf("detail repeats the same break %d times: %q", n, res.Detail)
}
}
// The fragments a real run produced. All of them scored as non-empty replies
// before checkNonEmpty looked for letters.
func TestNonEmptyNeedsLetters(t *testing.T) {
for _, body := range []string{
"{",
"{\n \"",
"15-16",
`{"`,
" ",
"...",
} {
if got := checkNonEmpty(body); got.Pass {
t.Errorf("checkNonEmpty(%q) passed — that is not a reply", body)
}
}
}
// And it must not start failing real replies. Latin counts as well as Cyrillic:
// answers about ssd or vpn are legitimately part English.
func TestNonEmptyAcceptsRealReplies(t *testing.T) {
for _, body := range []string{
"норм, а ты как?",
"вот что я нашла: ключ у соседа",
"ssd быстрее hdd.",
"9 минут.",
} {
if got := checkNonEmpty(body); !got.Pass {
t.Errorf("checkNonEmpty(%q) failed: %s", body, got.Detail)
}
}
}
+14 -1
View File
@@ -623,11 +623,24 @@ const (
CheckEllipsis = "ellipsis" // she finished the sentence
)
// A reply needs words in it, not just characters. This check used to test for a
// non-empty string, which scored 27/27 on a run where two replies were "{" and
// "{\n \"" — punctuation passed as content. Braces, quotes, digits and spaces
// are all empty in the only sense that matters.
//
// Digits alone fail too, and that is deliberate: the same run answered "сколько
// варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the
// wrong number. Whatever that is, it is not something she said.
func checkNonEmpty(body string) Result {
if strings.TrimSpace(body) == "" {
return Result{CheckNonEmpty, false, "empty reply"}
}
return Result{CheckNonEmpty, true, ""}
for _, r := range body {
if unicode.IsLetter(r) {
return Result{CheckNonEmpty, true, ""}
}
}
return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))}
}
// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
+58
View File
@@ -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())
}
}
}
+8 -3
View File
@@ -35,6 +35,8 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
}
// callAllPhrasingPaths hits every path that expects the JSON contract.
// LLMNudges must be set on the phraser under test: nudges come from templates
// by default and never reach the model at all.
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
t.Helper()
ctx := context.Background()
@@ -58,7 +60,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
t.Fatal("responseGrammar is empty")
}
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{})
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
callAllPhrasingPaths(t, p)
@@ -74,7 +76,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
func TestNoGrammarConfigDisablesIt(t *testing.T) {
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
callAllPhrasingPaths(t, p)
@@ -97,7 +99,10 @@ func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
// Russian body with an escaped quote inside, hand-built to test the contract.
func TestGrammarShapedJSONParses(t *testing.T) {
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
text, mood := parseResponseMood(raw)
text, mood, err := parseResponseMood(raw)
if err != nil {
t.Fatalf("grammar-shaped JSON did not parse: %v", err)
}
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
t.Errorf("response = %q, want %q", text, want)
}
+101 -17
View File
@@ -31,6 +31,10 @@ type LLMPhraser struct {
cmd *exec.Cmd
cancel context.CancelFunc
wg sync.WaitGroup
// tmpl — the hand-written Russian nudges. Default path for nudges; see
// Config.LLMNudges. nil only if the template file failed to load.
tmpl *NudgeTemplates
}
type Config struct {
@@ -46,6 +50,19 @@ type Config struct {
// nil ⇒ no block, the prompts stand alone.
ContextBlock func() string
// LLMNudges puts the model back in charge of nudge wording.
//
// Off by default, and that is a deliberate deprecation of LLM-phrased
// nudges: hand-written templates (nudges_ru_v1.json) word every nudge now.
// A nudge has nothing to be creative about, and measured over many runs the
// 0.8B broke the persona (formal "вы", plural imperatives, masculine
// self-reference) and invented facts and units. Templates score 15/15 on the
// nudge fixture, the model 11-13/15.
//
// The LLM path is kept, not deleted: flip this on to get it back. Chat,
// query and reminder phrasing are untouched and still go through the model.
LLMNudges bool
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
// The escape hatch exists because the target resident model — the
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
@@ -71,6 +88,7 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
cfg: cfg,
client: &http.Client{Timeout: cfg.Timeout},
cancel: cancel,
tmpl: loadNudgeTemplates(),
}
if err := p.start(ctx); err != nil {
cancel()
@@ -92,9 +110,22 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
client: &http.Client{Timeout: cfg.Timeout},
port: strings.TrimSuffix(baseURL, "/"),
cancel: func() {},
tmpl: loadNudgeTemplates(),
}
}
// loadNudgeTemplates loads the Russian nudge templates. A broken template file
// must not stop the daemon booting, so a failure logs and leaves the LLM path
// in charge of nudges.
func loadNudgeTemplates() *NudgeTemplates {
nt, err := NewNudgeTemplates(nil)
if err != nil {
log.Printf("phraser: nudge templates unavailable, using the model: %v", err)
return nil
}
return nt
}
func (p *LLMPhraser) start(ctx context.Context) error {
args := []string{
"-m", p.cfg.ModelPath,
@@ -185,12 +216,21 @@ func (p *LLMPhraser) Close() error {
}
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
// Templates first — see Config.LLMNudges for why this is the default.
if !p.cfg.LLMNudges && p.tmpl != nil {
return p.tmpl.PhraseNudge(ctx, c)
}
prompt := buildNudgePrompt(c)
resp, err := p.chat(ctx, prompt)
if err != nil {
return delivery.PhrasedNudge{}, err
}
body, mood := parseResponseMood(resp)
body, mood, perr := parseResponseMood(resp)
if perr != nil {
// Truncated JSON. Not a nudge — use the plain Russian fallback.
log.Printf("phraser: PhraseNudge: %v", perr)
body, mood = "", ""
}
if body == "" {
// fallback: try old body/summary format
body, _ = parsePhrase(resp)
@@ -216,11 +256,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
// prompt is the single tested source in router.KnowledgePrompt.
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
if err != nil || resp == "" {
return "не знаю.", nil
}
if text, _ := parseResponseMood(resp); text != "" {
text, _, perr := parseResponseMood(resp)
if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr)
return "не знаю.", nil
}
if text != "" {
return text, nil
}
return resp, nil
@@ -233,14 +278,19 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
`Он спрашивает: "%s". В твоих заметках по этому вопросу написано: "%s". Ответь ему коротко и своими словами. Если в заметках ответа нет — так и скажи.`,
utterance, strings.Join(notes, `"; "`),
)
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
if err != nil {
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
text, _, perr := parseResponseMood(resp)
if err != nil || perr != nil {
// Read the notes out rather than ship a broken fragment.
if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr)
}
if len(notes) == 1 {
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
}
if text, _ := parseResponseMood(resp); text != "" {
if text != "" {
return text, nil
}
return resp, nil
@@ -263,12 +313,17 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
combined += utterance
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
resp, err := p.chatWithMessages(ctx, msgs, 512)
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
log.Printf("phraser: PhraseChat: %v", err)
return "поговорили.", nil
}
if text, _ := parseResponseMood(resp); text != "" {
text, _, perr := parseResponseMood(resp)
if perr != nil {
log.Printf("phraser: PhraseChat: %v", perr)
return "поговорили.", nil
}
if text != "" {
return text, nil
}
// fallback: plain text without JSON
@@ -351,7 +406,12 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
if err != nil {
return delivery.PhrasedReminder{}, err
}
body, mood := parseResponseMood(resp)
body, mood, perr := parseResponseMood(resp)
if perr != nil {
// Truncated JSON. Fall through to the reminder's own text.
log.Printf("phraser: PhraseReminder: %v", perr)
body, mood = "", ""
}
if body == "" {
// fallback: try old body/summary format
body, _ = parsePhrase(resp)
@@ -396,10 +456,16 @@ type chatReq struct {
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
// is what lets the model close a string it opened with a quote inside. Length
// is bounded so a repetition loop truncates the field, not the JSON object.
//
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
// So the token cap was never what stopped it — this rule was. 1000 characters is
// roughly six Russian sentences, still short enough to stop a repetition loop.
const responseGrammar = `
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,400} "\""
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
ws ::= [ \t\n]*
`
@@ -633,21 +699,39 @@ type responseMood struct {
Mood string `json:"mood"`
}
// errBrokenJSON — the model started a JSON object and never finished it.
// That is a failed generation, not a reply. Callers must use their fallback.
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
// of thinking tokens and extra text before/after the JSON block. Returns
// ("", "") when no valid JSON is found.
func parseResponseMood(raw string) (response, mood string) {
// of thinking tokens and extra text before/after the JSON block.
//
// Three outcomes:
// - parsed fine → the fields, nil error.
// - output never looked like JSON → ("", "", nil). The caller may ship it
// as-is; small models sometimes answer in bare prose and that is fine.
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
// guarantees a valid *prefix*, so a generation that hits the token cap
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
// as a reply is the bug this error exists to stop.
func parseResponseMood(raw string) (response, mood string, err error) {
cleaned := strings.TrimSpace(raw)
start := strings.Index(cleaned, "{")
end := strings.LastIndex(cleaned, "}")
if start < 0 || end < 0 || end <= start {
return "", ""
if strings.HasPrefix(cleaned, "{") {
return "", "", errBrokenJSON
}
return "", "", nil
}
var parsed responseMood
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
return "", ""
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
if strings.HasPrefix(cleaned, "{") {
return "", "", errBrokenJSON
}
return "", "", nil
}
return parsed.Response, parsed.Mood
return parsed.Response, parsed.Mood, nil
}
func parsePhrase(raw string) (body, summary string) {
+261
View File
@@ -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)
}
+202
View File
@@ -0,0 +1,202 @@
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)
}
}
}
// Templates are the default: a nudge must not reach the model at all.
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{})
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if len(spy.grammars) != 0 {
t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
}
if !strings.Contains(strings.ToLower(pn.Body), "вод") {
t.Errorf("nudge is not the water template: %q", pn.Body)
}
}
// ...and the flag brings the model back.
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
spy := newGrammarSpy(t)
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if len(spy.grammars) != 1 {
t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
}
if pn.Body != "ага" {
t.Errorf("body = %q, want the model's reply", pn.Body)
}
}
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)
}
}
+129
View File
@@ -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": [
"Напоминаю: есть дело.",
"Пора вернуться к отложенному делу.",
"Одно дело ждёт тебя.",
"Напоминаю про дело из списка.",
"В списке осталось дело.",
"Дело всё ещё не сделано."
]
}
}
}