From b7eb53a3b48c493834311a14ff0b538f776f1445 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 13:35:39 +0400 Subject: [PATCH] 4.1 routing quality + 4.4 persona prompt - VoiceConfig: add QueryMinScore (default 0.55) + Persona config fields - voice.go: remove queryMinScore const, wire from cfg.Voice.QueryMinScore as reactiveHandler field - llmphraser.go: add Persona to Config, prepend to system prompts in chat and query paths (systemPrompt/querySystemPrompt methods) - main.go: pass personaFromCfg into both phraser config blocks - Makefile: add download-embedder target (Xenova/paraphrase-multilingual- MiniLM-L12-v2, ~90MB ONNX) - AGENTS.md: document embedder model download + libonnxruntime setup - server.go: fix pre-existing wg.Add vs wg.Wait data race using accept mutex. make test green, zero races across all 29 packages. --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++ Makefile | 27 +++++++++++++++++++++++- cmd/mavend/main.go | 12 +++++++++++ cmd/mavend/voice.go | 16 +++++++------- internal/config/config.go | 16 ++++++++++++++ internal/ipc/server.go | 19 ++++++++++++++--- internal/phraser/llmphraser.go | 23 ++++++++++++++++---- 7 files changed, 136 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b003a0a..7723dbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,44 @@ $R/mavweb -addr 127.0.0.1:9299 -core $R/mavend.sock & can snap mid-layout and silently drop elements (the PWA lang toggle "disappeared" this way). +## Embedder model for intent routing + +The router uses a multilingual sentence embedder to classify intents and recall +notes. Without it, the floor `HashEmbedder` is used — deterministic but weak +(Russian recall rarely clears the confidence gate, many commands fall to +"clarify"). + +**Download the embedder** (ONNX, ~90 MB): + +```sh +make download-embedder +``` + +This fetches `paraphrase-multilingual-MiniLM-L12-v2` (384-dim, 12-layer, +supports 50+ languages including Russian) to `models/embedder/`. + +**Also need ONNX Runtime** (`libonnxruntime.so`): + +```sh +curl -sL "https://github.com/microsoft/onnxruntime/releases/download/v1.15.1/onnxruntime-linux-x64-1.15.1.tgz" | tar xz +sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/ +``` + +**Configure in `deploy/mavend.json`**: + +```json +"voice": { + "embedder": { + "model_path": "models/embedder/model_quantized.onnx", + "tokenizer_path": "models/embedder/tokenizer.json", + "lib_path": "/usr/local/lib/libonnxruntime.so" + } +} +``` + +Without the embedder block, the daemon uses `HashEmbedder` (works, but weak on +Russian recall — you may see many "clarify" responses). + ## Web UI conventions - All server-rendered pages share `cmd/mavweb/static/ui.css` (served at diff --git a/Makefile b/Makefile index 36a0601..15cc734 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data -.PHONY: all build build-stt build-tts build-daemon build-client build-web build-poll build-caldav clean test run-stt run-tts run-web +.PHONY: all build build-stt build-tts build-daemon build-client build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder all: build @@ -71,5 +71,30 @@ deps-piper: -o /tmp/piper.tar.gz tar -xzf /tmp/piper.tar.gz -C deps/ +EMBEDDER_DIR := $(shell pwd)/models/embedder +EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/onnx/model_quantized.onnx +EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/tokenizer.json + +download-embedder: + mkdir -p $(EMBEDDER_DIR) + curl -sL "$(EMBEDDER_MODEL_URL)" -o "$(EMBEDDER_DIR)/model_quantized.onnx" + curl -sL "$(EMBEDDER_TOKENIZER_URL)" -o "$(EMBEDDER_DIR)/tokenizer.json" + @echo "" + @echo "embedder model downloaded to $(EMBEDDER_DIR)/" + @echo "To use it, add to mavend.json:" + @echo ' "voice": {' + @echo ' ...' + @echo ' "embedder": {' + @echo ' "model_path": "$(EMBEDDER_DIR)/model_quantized.onnx",' + @echo ' "tokenizer_path": "$(EMBEDDER_DIR)/tokenizer.json",' + @echo ' "lib_path": "/path/to/libonnxruntime.so"' + @echo ' }' + @echo ' }' + @echo "" + @echo "Install libonnxruntime.so from: https://github.com/microsoft/onnxruntime/releases" + @echo "e.g. on x86_64 Linux:" + @echo ' curl -sL "https://github.com/microsoft/onnxruntime/releases/download/v1.15.1/onnxruntime-linux-x64-1.15.1.tgz" | tar xz' + @echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/' + clean: rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index d55ecff..695e615 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -213,6 +213,7 @@ func run(args []string) error { NGpuLayers: cfg.Phraser.NGpuLayers, NCtx: cfg.Phraser.NCtx, Timeout: time.Duration(cfg.Phraser.Timeout), + Persona: personaFromCfg(cfg), } if pc.BinPath == "" { pc.BinPath = "llama-server" @@ -364,6 +365,7 @@ func run(args []string) error { NGpuLayers: cfg.Phraser.NGpuLayers, NCtx: cfg.Phraser.NCtx, Timeout: time.Duration(cfg.Phraser.Timeout), + Persona: personaFromCfg(cfg), } if pc.BinPath == "" { pc.BinPath = "llama-server" @@ -494,3 +496,13 @@ func run(args []string) error { log.Printf("mavend: bye") return nil } + +// personaFromCfg extracts the voice persona from the config, or returns "" +// when voice isn't configured. Used to pass a character prompt into the +// LLM phraser without requiring voice to be enabled. +func personaFromCfg(cfg *config.Config) string { + if cfg.Voice != nil { + return cfg.Voice.Persona + } + return "" +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 4a00f4d..157974b 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -228,6 +228,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem weatherLocation: weatherLocation, memStore: memStore, dialogueSessions: dialogueSessions, + queryMinScore: cfg.Voice.QueryMinScore, } // ----- the server (TCP listener) ----- @@ -261,6 +262,12 @@ type reactiveHandler struct { memStore memory.Store + // queryMinScore — the note-recall confidence gate. Top cosine below this ⇒ + // "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not + // load-bearing math (same posture as the presence thresholds). Set by + // wireVoice from VoiceConfig; default 0.55. + queryMinScore float64 + // dialogueSessions carries slots across turns for follow-ups (single-user // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore @@ -376,11 +383,6 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo // returns a non-empty string when the action path wants to OVERRIDE the // reply text (e.g. an action error the user should hear SPECIFICALLY, not // a generic "ok"). Errors surface as a short reply text the user hears. -// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒ -// "no note" instead of a guess. Hand-tuned for the ONNX embedder; a knob, not -// load-bearing math (same posture as the presence thresholds). -const queryMinScore = 0.55 - func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string { if dec.Clarify { return "" // the Replier phrases clarify @@ -541,13 +543,13 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) // a gap (spec's "not a guesser-of-truth"). Same instinct as the loop's // since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash // floor scores lexically and may rarely clear it. - if len(notes) == 0 || notes[0].Score < queryMinScore { + if len(notes) == 0 || notes[0].Score < h.queryMinScore { // Long-term memory recall (notes + facts) before general knowledge: // the notes table can't answer fact questions, but the memory store // indexes both. Only runs when notes-RAG already gave up → additive. if h.memStore != nil { if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { - if text, ok := bestRecall(hits, queryMinScore); ok { + if text, ok := bestRecall(hits, h.queryMinScore); ok { return text } } diff --git a/internal/config/config.go b/internal/config/config.go index eab56f2..73d9a08 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -189,6 +189,18 @@ type VoiceConfig struct { // Default 0.35 if unset. RouterThreshold float64 `json:"router_threshold,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 + // default if unset. + QueryMinScore float64 `json:"query_min_score,omitempty"` + + // Persona — optional prompt prefix that tunes maven's character. Prepended + // to every LLM system prompt (nudge phrasing, note queries, general + // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered + // Russian self-reference). Example: "Be formal and answer in English only." + Persona string `json:"persona,omitempty"` + // Weather — the weather provider config. nil ⇒ the daemon wires // the stub provider (returns ErrNotConfigured — "погода не настроена"). // Set provider to "open-meteo" to use the keyless Open-Meteo API. @@ -309,6 +321,7 @@ const ( DefaultRepeatInterval = 5 * time.Minute DefaultAutotuneInterval = 10 * time.Minute DefaultRouterThreshold = 0.35 + DefaultQueryMinScore = 0.55 DefaultToolTimeout = 30 * time.Second ) @@ -379,6 +392,9 @@ func (c *Config) applyDefaults() { if c.Voice.RouterThreshold <= 0 { c.Voice.RouterThreshold = DefaultRouterThreshold } + if c.Voice.QueryMinScore <= 0 { + c.Voice.QueryMinScore = DefaultQueryMinScore + } if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f3d55cc..f425332 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -281,9 +281,10 @@ type Server struct { api atomic.Value // stores CoreAPI path string - ln net.Listener - wg sync.WaitGroup - done chan struct{} + ln net.Listener + wg sync.WaitGroup + done chan struct{} + accept sync.Mutex // guards wg.Add vs Close's wg.Wait sequence // Check — optional authorization hook. dispatch runs it BEFORE method // dispatch, with the raw params, so the auth layer can make verdicts @@ -385,7 +386,12 @@ func (s *Server) Serve() error { return fmt.Errorf("ipc: accept: %w", err) } } + // wg.Add under accept mutex so Close's wg.Wait (also under accept) sees + // a consistent counter — a connection accepted just before Close closes + // the listener must be tracked before Wait starts. + s.accept.Lock() s.wg.Add(1) + s.accept.Unlock() go func(c net.Conn) { defer s.wg.Done() defer c.Close() @@ -747,7 +753,14 @@ func (s *Server) Close() error { close(s.done) } err := s.ln.Close() + // Under accept lock: after the listener closes, no new Accept can complete, + // so no new wg.Add will be called. The Wait is safe to observe the wg + // counter because any in-flight Accept that already got a conn either + // already called wg.Add (before releasing the lock) or will see the closed + // listener error and not call wg.Add at all. + s.accept.Lock() s.wg.Wait() + s.accept.Unlock() _ = os.Remove(s.path) return err } diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 27d1f35..4bf9c47 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -37,6 +37,7 @@ type Config struct { NGpuLayers int NCtx int Timeout time.Duration + Persona string // optional prompt prefix tuning maven's character } func DefaultConfig(modelPath string) Config { @@ -185,7 +186,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] if len(notes) == 1 { notes[0] = strings.TrimSpace(notes[0]) } - sys := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond with just the answer text, no JSON wrapper." + sys := p.querySystemPrompt() prompt := fmt.Sprintf( `The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`, utterance, strings.Join(notes, `"; "`), @@ -248,7 +249,7 @@ type chatResp struct { } func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) { - return p.chatWithSystem(ctx, systemPrompt(), userPrompt, 256) + return p.chatWithSystem(ctx, p.systemPrompt(), userPrompt, 256) } func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) { @@ -295,8 +296,22 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma return cr.Choices[0].Message.Content, nil } -func systemPrompt() string { - return `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"body": "full voice message", "summary": "brief away-channel version (<60 chars)"}. body is what the user hears on voice; summary is for push notifications (ntfy/telegram) — minimal, no exfil detail.` +func (p *LLMPhraser) systemPrompt() string { + base := `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"body": "full voice message", "summary": "brief away-channel version (<60 chars)"}. body is what the user hears on voice; summary is for push notifications (ntfy/telegram) — minimal, no exfil detail.` + if p.cfg.Persona != "" { + base = p.cfg.Persona + "\n\n" + base + } + return base +} + +// querySystemPrompt returns the system prompt for PhraseQuery (notes + general +// knowledge). Prepends the configured persona when set. +func (p *LLMPhraser) querySystemPrompt() string { + base := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond with just the answer text, no JSON wrapper." + if p.cfg.Persona != "" { + base = p.cfg.Persona + "\n\n" + base + } + return base } func buildNudgePrompt(c loop.Candidate) string {