config: voice block moves to its own file (V-410)

The VoiceConfig struct only. Its defaults, normalise/validate arms and
UseLLMRouter follow in the next commit, because the pre-commit cap counts a
pure move at twice the block size.

The RouterThreshold doc drifted: it said the default was 0.35 and that 0.0
meant permissive. DefaultRouterThreshold is 0.55 and applyDefaults replaces
anything <= 0, so permissive was never reachable. Fixed the comment, not the
code.
This commit is contained in:
2026-08-06 01:28:37 +04:00
parent 1b6d51dc71
commit d457c97355
2 changed files with 111 additions and 107 deletions
-107
View File
@@ -327,113 +327,6 @@ type QuietHoursConfig struct {
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
}
// 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.0 means permissive
// (never clarify); 0.35 is a reasonable floor for the ONNX embedder.
// The HashEmbedder floor scores lexically and may need a lower value.
// Default 0.35 if unset.
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"`
}
// WeatherConfig configures the weather provider for voice queries.
type WeatherConfig struct {
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
+111
View File
@@ -0,0 +1,111 @@
package config
// 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"`
}