Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b90952e55 |
@@ -0,0 +1,150 @@
|
|||||||
|
# Conversational phrasing eval — 31-07-2026
|
||||||
|
|
||||||
|
Every score measured tonight, on the three paths the nudge eval never touched:
|
||||||
|
chat, query-with-notes, and general knowledge.
|
||||||
|
|
||||||
|
**Short version: the plumbing got fixed and the score barely moved.** Grammar and
|
||||||
|
Russian prompts together took the composite from ~9 to ~14 of 27. Everything
|
||||||
|
still failing is the model not knowing things or not holding a constraint, and
|
||||||
|
prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 /
|
||||||
|
#400.
|
||||||
|
|
||||||
|
## How to reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf
|
||||||
|
MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \
|
||||||
|
deps/go/go/bin/go test -count=1 -timeout 40m \
|
||||||
|
-run TestLLMTalkBaseline ./internal/phraser/eval/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Three runs per configuration, always. The fixture is 27 cases, so one reply
|
||||||
|
changing moves the composite by 3.7 points — a single run cannot tell a real
|
||||||
|
change from sampling noise. This was learned the expensive way: an earlier claim
|
||||||
|
that "one nudge case fails every run" turned out to be three different cases
|
||||||
|
across three runs.
|
||||||
|
|
||||||
|
**Run the box otherwise idle.** See the contamination note at the bottom.
|
||||||
|
|
||||||
|
## Composite, per configuration
|
||||||
|
|
||||||
|
| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 |
|
||||||
|
| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 |
|
||||||
|
| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 |
|
||||||
|
| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 |
|
||||||
|
| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | |
|
||||||
|
|
||||||
|
"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."`
|
||||||
|
or `"поговорили."`. It is not a check, it is a health signal: those strings mean
|
||||||
|
the phraser gave up, and the eval scores them as ordinary bad replies.
|
||||||
|
|
||||||
|
## Per-check
|
||||||
|
|
||||||
|
| check | no grammar | + grammar | + RU prompts | + truncation fix |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 |
|
||||||
|
| feminine | — | — | 25, 24, 26 | 25, 25, 27 |
|
||||||
|
| address | — | — | 21, 22, 22 | 22, 21, 22 |
|
||||||
|
| ontopic | — | — | 17, 24, 18 | 17, 19, 14 |
|
||||||
|
|
||||||
|
`nonempty` reading 27/27 everywhere is not good news — it was a broken check.
|
||||||
|
It tested for a non-blank string, so replies of literally `{` and `"15-16"`
|
||||||
|
passed it. Fixed on `overnight/fix-truncation`; it needs a letter now.
|
||||||
|
|
||||||
|
## What each change actually bought
|
||||||
|
|
||||||
|
**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes
|
||||||
|
`Thinking Process:` as plain text with no tags, `stripThink` only handles
|
||||||
|
`</think>`, so the JSON never closed and the plain-text fallback shipped the
|
||||||
|
literal reasoning. `ellipsis` went 20→27 and `lang` 13→26. The router had been
|
||||||
|
using a grammar for ages; the phraser asking nicely in the prompt was the
|
||||||
|
oversight.
|
||||||
|
|
||||||
|
**Russian prompts (#400) — modest, plus a large latency win.** Chat 1.3→3.0
|
||||||
|
average, query 4.7→6.3, knowledge 6.3→5.0. All inside the run-to-run spread, so
|
||||||
|
"probably better on the paths it targeted, not provable in three runs". p50
|
||||||
|
latency dropped from ~11.5s to ~2.3s and that part is consistent across all
|
||||||
|
three runs — shorter prompts, and she stopped emitting English reasoning first.
|
||||||
|
|
||||||
|
**Truncation fix — necessary, and did not help the score.** Two real bugs
|
||||||
|
(replies of `{`, and a `nonempty` check that passed them), both fixed, and the
|
||||||
|
composite went nowhere. A complete rambling wrong answer fails the same checks a
|
||||||
|
truncated one did. Worth doing anyway: the daemon was shipping `{` to a
|
||||||
|
text-to-speech voice.
|
||||||
|
|
||||||
|
## The truncation bug, since the cause was counter-intuitive
|
||||||
|
|
||||||
|
The grammar's `string ::= ... {0,400}` rule was the cause, not the token cap.
|
||||||
|
Measured against Qwen3.5-0.8B at three caps — 256, 768 and 2048 — the reply came
|
||||||
|
back **exactly 400 characters every time, cut mid-word** (`"Нужно записать и,"`).
|
||||||
|
|
||||||
|
Then I raised the bound to 1000 while the cap was 768 tokens and made it worse:
|
||||||
|
Russian runs ~1.5 characters per token here, so generation died on the *token*
|
||||||
|
cap instead, mid-object, and the new guard correctly refused it and shipped
|
||||||
|
`"не знаю."` — 3, 3 and 6 fallbacks per run, from zero. **The two limits have to
|
||||||
|
agree.** 600 characters needs ~400 tokens; the cap is 1024.
|
||||||
|
|
||||||
|
## Where the remaining failures live
|
||||||
|
|
||||||
|
`address` is stuck at 21-22 of 27 and `ontopic` at 14-19. Both resist prompting.
|
||||||
|
|
||||||
|
**The prompt now explicitly forbids exactly what she does.** It says never "вы",
|
||||||
|
use the singular — and she writes `вашей`, `подождите`, `делаете`, `хотите`,
|
||||||
|
`напишите`. Telling a 0.8B "never do X" does not work. Same for
|
||||||
|
`feminine`: `я готов`, `я понял`, `я нашел`, `я заметил`, `я сказал`.
|
||||||
|
|
||||||
|
**Some of `ontopic` is the fixture, not the model.** `chat-how-are-you` got
|
||||||
|
`"Привет! Я здесь, чтобы поговорить. Как дела сегодня?"` — a fine reply that
|
||||||
|
fails because `want_any` is `[норм, хорош, порядк, тут, работ]`. It fails in
|
||||||
|
every run, so it inflates the count. The `ontopic` column currently measures the
|
||||||
|
fixture as much as the model. Not fixed yet, deliberately: changing it would
|
||||||
|
break comparability with the runs above.
|
||||||
|
|
||||||
|
**Two replies worth reading, because they are not fixable by prompting:**
|
||||||
|
|
||||||
|
- Thunder and lightning: *"Скорость молнии — 8-10 тысяч километров в секунду, но
|
||||||
|
звук — 300 метров в секунду, что делает молнию громче."* Confidently wrong,
|
||||||
|
and it concludes lightning is *louder* rather than sound being *slower*.
|
||||||
|
- "расскажи обо мне": *"Ты — прекрасное существо, с душой и вниманием… Спасибо за
|
||||||
|
твою улыбку… О тебе — заповедь любви."* Sycophantic filler, zero information,
|
||||||
|
and precisely the "not a relationship" non-goal.
|
||||||
|
- Boiling an egg: `"15-16"` one run, `"1"` another. No unit, wrong number.
|
||||||
|
|
||||||
|
The first argues for reading instead of recalling (#403 — Kiwix retrieval scores
|
||||||
|
8/8 on the same questions given English keywords). The second and third argue
|
||||||
|
for templates on the paths where correctness matters (#392).
|
||||||
|
|
||||||
|
## Contamination note — how the last row got voided
|
||||||
|
|
||||||
|
I started the query-rewrite agent against the same llama-server the sweep was
|
||||||
|
using, and assumed contention would only affect latency. It did not. The
|
||||||
|
knowledge path collapsed to 0 of 9 with eight canned `"не знаю."` replies, p95
|
||||||
|
tripled to 23.7s, and **the report still said "0 errors"**.
|
||||||
|
|
||||||
|
That is Vikunja #397, and it is worse than filed: a merely *busy* server
|
||||||
|
produces a clean-looking report with a third of the fixture silently answering
|
||||||
|
`"не знаю."`. `PhraseChat` and `PhraseQuery` swallow every failure and return a
|
||||||
|
hardcoded string, so infrastructure trouble is indistinguishable from bad
|
||||||
|
phrasing in the score. The talk test guards the *start* and *end* of a run with
|
||||||
|
a model check, which catches a dead server but not a loaded one.
|
||||||
|
|
||||||
|
**Until #397 is fixed, treat any run made on a busy box as void.**
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Re-run 600ch/1024tok clean, to fill the void row.
|
||||||
|
- Score `Qwen3.5-2B-UD-Q4_K_XL` (already at `/mnt/hdd1/llms/qwen3.5/`, never
|
||||||
|
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||||
|
this box, owner's call.
|
||||||
|
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||||
|
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
||||||
|
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||||
|
older generation, so that result does not predict the small ones.
|
||||||
|
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||||
|
measures the model.
|
||||||
|
- #397 first if anything, since it decides whether any of the above is
|
||||||
|
trustworthy.
|
||||||
@@ -278,7 +278,6 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
@@ -449,7 +448,6 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
|
|||||||
+1
-2
@@ -10,8 +10,7 @@
|
|||||||
"bin_path": "llama-server",
|
"bin_path": "llama-server",
|
||||||
"n_gpu_layers": 99,
|
"n_gpu_layers": 99,
|
||||||
"n_ctx": 2048,
|
"n_ctx": 2048,
|
||||||
"timeout": "60s",
|
"timeout": "60s"
|
||||||
"llm_nudges": false
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
|
|||||||
@@ -369,12 +369,6 @@ type PhraserConfig struct {
|
|||||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||||
NCtx int `json:"n_ctx,omitempty"`
|
NCtx int `json:"n_ctx,omitempty"`
|
||||||
Timeout Duration `json:"timeout,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
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||||
|
|||||||
@@ -35,27 +35,6 @@ 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) {
|
func TestLoadDurationsParse(t *testing.T) {
|
||||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||||
c, err := Load(p)
|
c, err := Load(p)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,6 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
// 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) {
|
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -60,7 +58,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
t.Fatal("responseGrammar is empty")
|
t.Fatal("responseGrammar is empty")
|
||||||
}
|
}
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
@@ -76,7 +74,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ type LLMPhraser struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
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 {
|
type Config struct {
|
||||||
@@ -50,19 +46,6 @@ type Config struct {
|
|||||||
// nil ⇒ no block, the prompts stand alone.
|
// nil ⇒ no block, the prompts stand alone.
|
||||||
ContextBlock func() string
|
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).
|
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||||
// The escape hatch exists because the target resident model — the
|
// The escape hatch exists because the target resident model — the
|
||||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||||
@@ -88,7 +71,6 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
tmpl: loadNudgeTemplates(),
|
|
||||||
}
|
}
|
||||||
if err := p.start(ctx); err != nil {
|
if err := p.start(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -110,22 +92,9 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
|||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
port: strings.TrimSuffix(baseURL, "/"),
|
port: strings.TrimSuffix(baseURL, "/"),
|
||||||
cancel: func() {},
|
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 {
|
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"-m", p.cfg.ModelPath,
|
"-m", p.cfg.ModelPath,
|
||||||
@@ -216,10 +185,6 @@ func (p *LLMPhraser) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, 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)
|
prompt := buildNudgePrompt(c)
|
||||||
resp, err := p.chat(ctx, prompt)
|
resp, err := p.chat(ctx, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
{
|
|
||||||
"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": [
|
|
||||||
"Напоминаю: есть дело.",
|
|
||||||
"Пора вернуться к отложенному делу.",
|
|
||||||
"Одно дело ждёт тебя.",
|
|
||||||
"Напоминаю про дело из списка.",
|
|
||||||
"В списке осталось дело.",
|
|
||||||
"Дело всё ещё не сделано."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user