From 93c1a41d4a7bba05613629d09b34fadc49a2ffd3 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 11:47:57 +0400 Subject: [PATCH] Route with the resident model by default The two things that made this unsafe are fixed: the router can now refuse, and slot extraction runs on its decisions. On the held-out fixture it gets 63.2% of intents right against the classifier's 50.0%, with no route errors. It costs about a second a turn instead of 30ms. The flag is a pointer now, so leaving it out of the config means on and only writing false turns it off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/voice.go | 10 ++++---- deploy/mavend.json | 2 +- internal/config/config.go | 42 ++++++++++++++++++++++++++-------- internal/config/config_test.go | 20 ++++++++++++---- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 868ff91..542bd3d 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -206,11 +206,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if threshold <= 0 { threshold = config.DefaultRouterThreshold } - // Both routing paths are weak on held-out utterances — the classifier gets - // 36.8% of intents right, the resident model 50.0% and much slower. Off by - // default (see config.VoiceConfig.LLMRouter); the classifier always stays - // wired as the fallback, so a model error never breaks a turn. - rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.LLMRouter, llmClient)) + // The resident model routes by default: 63.2% of held-out intents right + // against the classifier's 50.0%, at about 1s a turn instead of 30ms (see + // config.VoiceConfig.LLMRouter). The classifier always stays wired as the + // fallback, so a model error never breaks a turn. + rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)) // ----- sessions registry (shared with voicesink) ----- sessions := voice.NewSessions() diff --git a/deploy/mavend.json b/deploy/mavend.json index 3687d30..d29bb05 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -40,7 +40,7 @@ "tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json", "lib_path": "/opt/maven/lib/libonnxruntime.so" }, - "llm_router": false, + "llm_router": true, "tool_timeout": "30s", "tools": [ { "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false }, diff --git a/internal/config/config.go b/internal/config/config.go index 5205f4b..328f9cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -258,18 +258,25 @@ type VoiceConfig struct { RouterThreshold float64 `json:"router_threshold,omitempty"` // LLMRouter — route with the resident model instead of the embedding - // classifier. Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md) - // the model gets 50.0% of intents right against the classifier's 36.8%, but - // it costs about 800ms per turn instead of 30ms. + // classifier. On by default since Vikunja #320. // - // TODO: the default stays false until this lands. - // Extractor.Extract never runs on an LLM decision, so acts arrive with no - // Fn and reminders with no Time. Turning this on today makes routing more - // accurate and less safe. + // Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of + // intents right against the classifier's 50.0%, and no route errors. It + // costs about 1s per turn instead of 30ms. // - // The router can now refuse: it answers "unknown" when it cannot route, and - // the turn drops to the classifier and its clarify gate (Vikunja #359). - LLMRouter bool `json:"llm_router,omitempty"` + // It is safe to leave on. The model can refuse — it answers "unknown" when + // it cannot route, and the turn drops to the classifier and its clarify + // gate. Any LLM error does the same, so a turn never breaks on the model. + // Slot extraction runs on LLM decisions too, so acts get their Fn and + // reminders their Time. + // + // Set it false to go back to the classifier, e.g. on a box with no + // llama-server or when 1s a turn is too slow. + // + // It is a pointer so that "missing from the file" and "explicitly false" + // are different things: missing means on, false means off. Read it with + // UseLLMRouter(), not directly. + LLMRouter *bool `json:"llm_router,omitempty"` // QueryMinScore — the note-recall confidence gate. Top cosine below this // ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55); @@ -405,6 +412,8 @@ const ( DefaultRouterThreshold = 0.55 DefaultQueryMinScore = 0.55 DefaultToolTimeout = 30 * time.Second + // DefaultLLMRouter — route with the resident model unless told otherwise. + DefaultLLMRouter = true DefaultFactEnrichmentInterval = 30 * time.Second ) @@ -489,6 +498,10 @@ func (c *Config) applyDefaults() { if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } + if c.Voice.LLMRouter == nil { + on := DefaultLLMRouter + c.Voice.LLMRouter = &on + } } // routines: default severity to care-class (1) — the safe floor: a @@ -507,6 +520,15 @@ func (c *Config) applyDefaults() { } } +// UseLLMRouter reports whether to route with the resident model. Unset means +// on; only an explicit false in the config turns it off. +func (v *VoiceConfig) UseLLMRouter() bool { + if v == nil || v.LLMRouter == nil { + return DefaultLLMRouter + } + return *v.LLMRouter +} + func (c *Config) validate() error { if c.Phraser != nil { if c.Phraser.ModelPath == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 61733ee..33f07ff 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -171,14 +171,26 @@ func TestWeatherConfigNilOK(t *testing.T) { } } -func TestLLMRouterDefaultsOff(t *testing.T) { +func TestLLMRouterDefaultsOn(t *testing.T) { p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } - if c.Voice.LLMRouter { - t.Error("voice.llm_router absent should mean false") + if !c.Voice.UseLLMRouter() { + t.Error("voice.llm_router absent should mean on") + } +} + +// Missing and explicitly false must not mean the same thing. +func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) { + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.Voice.UseLLMRouter() { + t.Error("voice.llm_router false should turn it off") } } @@ -188,7 +200,7 @@ func TestLLMRouterRead(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if !c.Voice.LLMRouter { + if !c.Voice.UseLLMRouter() { t.Error("voice.llm_router true was not read") } }