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
This commit is contained in:
@@ -278,6 +278,7 @@ 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 == "" {
|
||||||
@@ -448,6 +449,7 @@ 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 == "" {
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"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,6 +369,12 @@ 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,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) {
|
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)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ 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()
|
||||||
@@ -58,7 +60,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{})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
@@ -74,7 +76,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})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ 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 {
|
||||||
@@ -46,6 +50,19 @@ 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
|
||||||
@@ -71,6 +88,7 @@ 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()
|
||||||
@@ -92,9 +110,22 @@ 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,
|
||||||
@@ -185,6 +216,10 @@ 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 {
|
||||||
|
|||||||
@@ -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) {
|
func TestNudgeTemplatesPhraseNudge(t *testing.T) {
|
||||||
nt := newTestTemplates(t, 5)
|
nt := newTestTemplates(t, 5)
|
||||||
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
|
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||||
|
|||||||
Reference in New Issue
Block a user