From bf99fd4192bb0b24f77d73dae184668825368846 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:15:30 +0400 Subject: [PATCH] Add a voice.llm_router flag, default off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires cmd/mavend/voice.go to build the LLM router when the operator asks for it. Default false, so nothing changes on the deploy box. Look at pickLLMRouter: the flag on with no llama-server logs one line and keeps the classifier, it never fails a turn. The default stays off until the router can refuse (#359) and the extractor runs on LLM decisions — both noted as TODOs in config.go. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/llmrouter_flag_test.go | 28 ++++++++++++++++++++++++++++ cmd/mavend/voice.go | 23 ++++++++++++++++++++--- deploy/mavend.json | 1 + internal/config/config.go | 14 ++++++++++++++ internal/config/config_test.go | 22 ++++++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 cmd/mavend/llmrouter_flag_test.go diff --git a/cmd/mavend/llmrouter_flag_test.go b/cmd/mavend/llmrouter_flag_test.go new file mode 100644 index 0000000..ac81977 --- /dev/null +++ b/cmd/mavend/llmrouter_flag_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/llm" +) + +func TestPickLLMRouterOff(t *testing.T) { + if r := pickLLMRouter(false, llm.New("http://127.0.0.1:1", time.Second)); r != nil { + t.Error("flag off should give no LLM router") + } +} + +// The operator can turn the flag on without an LLM phraser configured. That must +// leave the classifier running, not panic. +func TestPickLLMRouterOnWithoutClient(t *testing.T) { + if r := pickLLMRouter(true, nil); r != nil { + t.Error("no llama-server should give no LLM router") + } +} + +func TestPickLLMRouterOn(t *testing.T) { + if r := pickLLMRouter(true, llm.New("http://127.0.0.1:1", time.Second)); r == nil { + t.Error("flag on with a client should give an LLM router") + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 9bbd226..97f5535 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -199,8 +199,6 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if lp, ok := phr.(*phraser.LLMPhraser); ok { llmClient = llm.New(lp.BaseURL(), 60*time.Second) } - // LLM router disabled — the classifier handles routing reliably. - // ----- router (the cascade; floor examples seed the classifier) ----- // The act matcher's allowlist is exactly the enabled tool names — the // router only matches acts the executor can run (one source of truth). @@ -208,7 +206,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if threshold <= 0 { threshold = config.DefaultRouterThreshold } - rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled + // 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)) // ----- sessions registry (shared with voicesink) ----- sessions := voice.NewSessions() @@ -1048,6 +1050,21 @@ func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (v return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil } +// pickLLMRouter returns the LLM router when the operator asked for it and there +// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then +// routes with the classifier, so an unusable setting costs accuracy, not turns. +func pickLLMRouter(enabled bool, c *llm.Client) *router.LLMRouter { + if !enabled { + return nil + } + if c == nil { + log.Printf("voice: voice.llm_router is on but there is no llama-server to route with (the phraser is not an LLM phraser) — using the classifier instead") + return nil + } + log.Printf("voice: LLM router enabled") + return router.NewLLMRouter(c) +} + // buildRouter constructs the reactive-path router with the given embedder // and confidence threshold. // - stage-0 grammars from DefaultActMatcher whose fn allowlist is exactly diff --git a/deploy/mavend.json b/deploy/mavend.json index 3a8c122..0a62c11 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -40,6 +40,7 @@ "tokenizer_path": "/opt/maven/models/embedder/tokenizer.json", "lib_path": "/opt/maven/lib/libonnxruntime.so" }, + "llm_router": false, "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 cca49e0..aa98fd5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,6 +257,20 @@ type VoiceConfig struct { // Default 0.35 if unset. 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. + // + // TODO: the default stays false until two things land. + // 1. The LLM router cannot refuse. LLMRouter.Route hardcodes + // Confidence: 1.0, so the stage-3 clarify gate never fires and an + // unclear utterance becomes a confident wrong action (Vikunja #359). + // 2. 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. + 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); // the HashEmbedder floor scores lexically and may never clear it. 0.55 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be31962..61733ee 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -171,6 +171,28 @@ func TestWeatherConfigNilOK(t *testing.T) { } } +func TestLLMRouterDefaultsOff(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") + } +} + +func TestLLMRouterRead(t *testing.T) { + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.Voice.LLMRouter { + t.Error("voice.llm_router true was not read") + } +} + func TestDurationRoundTrip(t *testing.T) { d := Duration(15 * time.Minute) b, err := d.MarshalJSON()