69a6eb0fb9
Same block, second half. applyDefaults and validate now call normaliseVoice and validateVoice, in the same order the inline arms ran.
191 lines
8.2 KiB
Go
191 lines
8.2 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// Voice defaults, applied in normaliseVoice.
|
|
const (
|
|
DefaultRouterThreshold = 0.55
|
|
DefaultQueryMinScore = 0.55
|
|
// Read off the margin sweep in internal/memory/recalleval on the e5
|
|
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
|
|
// false recall from 5/5 to 1/5. Every larger delta costs real recall
|
|
// without removing that last one until 0.020, which drops recall to 44%.
|
|
DefaultQueryMinMargin = 0.008
|
|
// DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts.
|
|
DefaultClarifyMaxAttempts = 3
|
|
DefaultToolTimeout = 30 * time.Second
|
|
// DefaultLLMRouter — route with the resident model unless told otherwise.
|
|
DefaultLLMRouter = true
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// normaliseVoice applies the block's defaults. An absent block stays nil: the
|
|
// surface is off and there is nothing to tune.
|
|
func (c *Config) normaliseVoice() {
|
|
if c.Voice == nil {
|
|
return
|
|
}
|
|
if c.Voice.RouterThreshold <= 0 {
|
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
|
}
|
|
if c.Voice.QueryMinScore <= 0 {
|
|
c.Voice.QueryMinScore = DefaultQueryMinScore
|
|
}
|
|
// Unset ⇒ default. Negative is how you turn the margin off on purpose,
|
|
// so it is clamped to 0 rather than replaced by the default.
|
|
switch {
|
|
case c.Voice.QueryMinMargin == 0:
|
|
c.Voice.QueryMinMargin = DefaultQueryMinMargin
|
|
case c.Voice.QueryMinMargin < 0:
|
|
c.Voice.QueryMinMargin = 0
|
|
}
|
|
if c.Voice.ClarifyMaxAttempts <= 0 {
|
|
c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts
|
|
}
|
|
if c.Voice.ToolTimeout <= 0 {
|
|
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
|
}
|
|
if c.Voice.LLMRouter == nil {
|
|
on := DefaultLLMRouter
|
|
c.Voice.LLMRouter = &on
|
|
}
|
|
}
|
|
|
|
// validateVoice refuses a surface that would listen nowhere, and an embedder
|
|
// block with only some of its three paths filled in.
|
|
func (c *Config) validateVoice() error {
|
|
if c.Voice == nil || !c.Voice.Enabled {
|
|
return nil
|
|
}
|
|
if c.Voice.Bind == "" {
|
|
return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address")
|
|
}
|
|
if e := c.Voice.Embedder; e != nil {
|
|
if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
|
|
return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
|
|
// seams.
|
|
//
|
|
// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in
|
|
// production; "127.0.0.1:9100" for the local smoke). Lang is the default
|
|
// language hint passed to both stt and tts (per-call overrides later).
|
|
//
|
|
// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the
|
|
// in-process stt.Stub (the "no models on disk" floor — the loop is
|
|
// exercisable end-to-end with a deterministic no-model transcriber).
|
|
// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix
|
|
// socket (cmd/mavsttd serves the other end; production swaps in a
|
|
// faster-whisper handler in cmd/mavsttd, no daemon or stt-package
|
|
// change). Tts mirrors for tts.Remote + cmd/mavttsd.
|
|
//
|
|
// Embedder configures the router's sentence embedder. When all three
|
|
// paths are set, the daemon constructs an ONNX multilingual embedder
|
|
// (in-process); when nil, it falls back to the floor HashEmbedder stub
|
|
// (deterministic, no model files required — good for CI and smoke).
|
|
//
|
|
// The daemon refuses to start if Voice.Enabled but Bind is empty — the
|
|
// bind is the one operational config the surface can't default (127.0.0.1
|
|
// is too relaxed for production, a wg-tunnel address is the user's);
|
|
// surfacing the gap explicitly beats an idle listener the user thinks is
|
|
// wired but isn't reachable.
|
|
type VoiceConfig struct {
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
Bind string `json:"bind,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
Stt *WorkerConfig `json:"stt,omitempty"`
|
|
Tts *TtsConfig `json:"tts,omitempty"`
|
|
Embedder *EmbedderConfig `json:"embedder,omitempty"`
|
|
|
|
// RouterThreshold — the minimum confidence score for the intent classifier
|
|
// (stage 3 gate). Below this → clarify, don't guess. 0 ⇒
|
|
// DefaultRouterThreshold (0.55), tuned for the ONNX embedder; the
|
|
// HashEmbedder floor scores lexically and may need a lower value.
|
|
//
|
|
// There is no way to ask for "permissive, never clarify" through this
|
|
// field: normaliseVoice replaces anything ≤ 0 with the default, so a
|
|
// written 0 is the default and a written negative is too.
|
|
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
|
|
|
// LLMRouter — route with the resident model instead of the embedding
|
|
// classifier. On by default since Vikunja #320.
|
|
//
|
|
// Measured on the held-out fixture (docs/evals/2026-07-31-routing.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.
|
|
//
|
|
// 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);
|
|
// the HashEmbedder floor scores lexically and may never clear it. 0.55
|
|
// default if unset.
|
|
QueryMinScore float64 `json:"query_min_score,omitempty"`
|
|
|
|
// QueryMinMargin — the second half of the recall gate: the top hit must
|
|
// beat the runner-up by more than this. The absolute score above cannot do
|
|
// the job on its own, because the e5 embedder puts every cosine in one
|
|
// narrow high band, so a made-up question scores as high as a real one.
|
|
// The margin asks whether one note is clearly the best instead.
|
|
// Negative ⇒ off. 0 ⇒ the default below.
|
|
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
|
|
|
|
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
|
|
// request before she gives up and says she did not understand. Default 3.
|
|
ClarifyMaxAttempts int `json:"clarify_max_attempts,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"`
|
|
|
|
// OwnerName / City — optional facts about the owner, added to the shared
|
|
// context block (internal/persona). Empty is fine: the block still states
|
|
// who he is grammatically (a man, addressed as "ты") and the current time.
|
|
// Nothing about correct behaviour may depend on these being filled in.
|
|
OwnerName string `json:"owner_name,omitempty"`
|
|
City string `json:"city,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.
|
|
Weather *WeatherConfig `json:"weather,omitempty"`
|
|
|
|
// Tools — the enabled act allowlist. Each is a spoken verb → argv the
|
|
// executor runs (args from the utterance appended). Editing this set is the
|
|
// human-only "enable" act (per spec); maven can't add to it from a request.
|
|
// Empty ⇒ every act is refused (nothing enabled).
|
|
Tools []ToolConfig `json:"tools,omitempty"`
|
|
|
|
// ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s).
|
|
ToolTimeout Duration `json:"tool_timeout,omitempty"`
|
|
}
|