From 6b67e6f3c2ef2eaba96238f3546cd6bc8b8951b0 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 18:21:52 +0400 Subject: [PATCH] Word nudges from templates by default, model optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/main.go | 2 ++ deploy/mavend.json | 3 +- internal/config/config.go | 6 ++++ internal/config/config_test.go | 21 ++++++++++++++ internal/phraser/grammar_test.go | 6 ++-- internal/phraser/llmphraser.go | 35 ++++++++++++++++++++++++ internal/phraser/nudge_templates_test.go | 32 ++++++++++++++++++++++ 7 files changed, 102 insertions(+), 3 deletions(-) diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index b1f69cd..2966968 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -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 == "" { diff --git a/deploy/mavend.json b/deploy/mavend.json index 05d50cc..2c684b4 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -10,7 +10,8 @@ "bin_path": "llama-server", "n_gpu_layers": 99, "n_ctx": 2048, - "timeout": "60s" + "timeout": "60s", + "llm_nudges": false }, "telegram": { diff --git a/internal/config/config.go b/internal/config/config.go index 37220f2..33f1f26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 33f07ff..dae1b6b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) diff --git a/internal/phraser/grammar_test.go b/internal/phraser/grammar_test.go index bf6e713..2117c98 100644 --- a/internal/phraser/grammar_test.go +++ b/internal/phraser/grammar_test.go @@ -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) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 3fc90c3..1bf1eb9 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -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,6 +216,10 @@ 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 { diff --git a/internal/phraser/nudge_templates_test.go b/internal/phraser/nudge_templates_test.go index a12f10b..c33bd6a 100644 --- a/internal/phraser/nudge_templates_test.go +++ b/internal/phraser/nudge_templates_test.go @@ -158,6 +158,38 @@ func TestRuSinceWords(t *testing.T) { } } +// 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, ""))