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.
This commit is contained in:
@@ -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
|
can snap mid-layout and silently drop elements (the PWA lang toggle
|
||||||
"disappeared" this way).
|
"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
|
## Web UI conventions
|
||||||
|
|
||||||
- All server-rendered pages share `cmd/mavweb/static/ui.css` (served at
|
- All server-rendered pages share `cmd/mavweb/static/ui.css` (served at
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
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
|
all: build
|
||||||
|
|
||||||
@@ -71,5 +71,30 @@ deps-piper:
|
|||||||
-o /tmp/piper.tar.gz
|
-o /tmp/piper.tar.gz
|
||||||
tar -xzf /tmp/piper.tar.gz -C deps/
|
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:
|
clean:
|
||||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav
|
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav
|
||||||
|
|||||||
@@ -213,6 +213,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),
|
||||||
|
Persona: personaFromCfg(cfg),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
pc.BinPath = "llama-server"
|
pc.BinPath = "llama-server"
|
||||||
@@ -364,6 +365,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),
|
||||||
|
Persona: personaFromCfg(cfg),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
pc.BinPath = "llama-server"
|
pc.BinPath = "llama-server"
|
||||||
@@ -494,3 +496,13 @@ func run(args []string) error {
|
|||||||
log.Printf("mavend: bye")
|
log.Printf("mavend: bye")
|
||||||
return nil
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
+9
-7
@@ -228,6 +228,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
|
queryMinScore: cfg.Voice.QueryMinScore,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the server (TCP listener) -----
|
// ----- the server (TCP listener) -----
|
||||||
@@ -261,6 +262,12 @@ type reactiveHandler struct {
|
|||||||
|
|
||||||
memStore memory.Store
|
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
|
// dialogueSessions carries slots across turns for follow-ups (single-user
|
||||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||||
dialogueSessions *dialogue.SessionStore
|
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
|
// 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
|
// 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.
|
// 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 {
|
func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string {
|
||||||
if dec.Clarify {
|
if dec.Clarify {
|
||||||
return "" // the Replier phrases 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
|
// 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
|
// since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash
|
||||||
// floor scores lexically and may rarely clear it.
|
// 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:
|
// Long-term memory recall (notes + facts) before general knowledge:
|
||||||
// the notes table can't answer fact questions, but the memory store
|
// the notes table can't answer fact questions, but the memory store
|
||||||
// indexes both. Only runs when notes-RAG already gave up → additive.
|
// indexes both. Only runs when notes-RAG already gave up → additive.
|
||||||
if h.memStore != nil {
|
if h.memStore != nil {
|
||||||
if hits, herr := h.memStore.Search(ctx, vec, 3); herr == 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
|
return text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ type VoiceConfig struct {
|
|||||||
// Default 0.35 if unset.
|
// Default 0.35 if unset.
|
||||||
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
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
|
// Weather — the weather provider config. nil ⇒ the daemon wires
|
||||||
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
||||||
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
||||||
@@ -309,6 +321,7 @@ const (
|
|||||||
DefaultRepeatInterval = 5 * time.Minute
|
DefaultRepeatInterval = 5 * time.Minute
|
||||||
DefaultAutotuneInterval = 10 * time.Minute
|
DefaultAutotuneInterval = 10 * time.Minute
|
||||||
DefaultRouterThreshold = 0.35
|
DefaultRouterThreshold = 0.35
|
||||||
|
DefaultQueryMinScore = 0.55
|
||||||
DefaultToolTimeout = 30 * time.Second
|
DefaultToolTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,6 +392,9 @@ func (c *Config) applyDefaults() {
|
|||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
}
|
}
|
||||||
|
if c.Voice.QueryMinScore <= 0 {
|
||||||
|
c.Voice.QueryMinScore = DefaultQueryMinScore
|
||||||
|
}
|
||||||
if c.Voice.ToolTimeout <= 0 {
|
if c.Voice.ToolTimeout <= 0 {
|
||||||
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -281,9 +281,10 @@ type Server struct {
|
|||||||
api atomic.Value // stores CoreAPI
|
api atomic.Value // stores CoreAPI
|
||||||
path string
|
path string
|
||||||
|
|
||||||
ln net.Listener
|
ln net.Listener
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
accept sync.Mutex // guards wg.Add vs Close's wg.Wait sequence
|
||||||
|
|
||||||
// Check — optional authorization hook. dispatch runs it BEFORE method
|
// Check — optional authorization hook. dispatch runs it BEFORE method
|
||||||
// dispatch, with the raw params, so the auth layer can make verdicts
|
// 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)
|
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.wg.Add(1)
|
||||||
|
s.accept.Unlock()
|
||||||
go func(c net.Conn) {
|
go func(c net.Conn) {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
@@ -747,7 +753,14 @@ func (s *Server) Close() error {
|
|||||||
close(s.done)
|
close(s.done)
|
||||||
}
|
}
|
||||||
err := s.ln.Close()
|
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.wg.Wait()
|
||||||
|
s.accept.Unlock()
|
||||||
_ = os.Remove(s.path)
|
_ = os.Remove(s.path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Config struct {
|
|||||||
NGpuLayers int
|
NGpuLayers int
|
||||||
NCtx int
|
NCtx int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
Persona string // optional prompt prefix tuning maven's character
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig(modelPath string) Config {
|
func DefaultConfig(modelPath string) Config {
|
||||||
@@ -185,7 +186,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
if len(notes) == 1 {
|
if len(notes) == 1 {
|
||||||
notes[0] = strings.TrimSpace(notes[0])
|
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(
|
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.`,
|
`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, `"; "`),
|
utterance, strings.Join(notes, `"; "`),
|
||||||
@@ -248,7 +249,7 @@ type chatResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
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) {
|
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
|
return cr.Choices[0].Message.Content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func systemPrompt() string {
|
func (p *LLMPhraser) 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.`
|
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 {
|
func buildNudgePrompt(c loop.Candidate) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user