//! Runtime-tunable settings loaded from `settings.toml`. //! //! Bootstrap config (port, data paths, log paths, auth) lives in [`crate::config::Config`]. //! Edits to `settings.toml` take effect on the next server start — hot //! reload is a follow-up plan. use serde::Deserialize; #[derive(Debug, Clone, Default, Deserialize)] pub struct Settings { #[serde(default)] pub retention: RetentionSettings, #[serde(default)] pub whisper: WhisperSettings, #[serde(default)] pub canary: CanarySettings, #[serde(default)] pub ollama: OllamaSettings, #[serde(default)] pub llm: LlmSettings, } #[derive(Debug, Clone, Deserialize)] pub struct RetentionSettings { #[serde(default = "default_retention_audio_days")] pub audio_days: u32, #[serde(default = "default_retention_transcript_days")] pub transcript_days: u32, #[serde(default = "default_retention_document_days")] pub document_days: u32, } impl Default for RetentionSettings { fn default() -> Self { Self { audio_days: default_retention_audio_days(), transcript_days: default_retention_transcript_days(), document_days: default_retention_document_days(), } } } fn default_retention_audio_days() -> u32 { 30 } fn default_retention_transcript_days() -> u32 { 30 } fn default_retention_document_days() -> u32 { 0 } #[derive(Debug, Clone, Deserialize)] pub struct WhisperSettings { #[serde(default = "default_whisper_url")] pub url: String, #[serde(default = "default_whisper_timeout_seconds")] pub timeout_seconds: u64, } impl Default for WhisperSettings { fn default() -> Self { Self { url: default_whisper_url(), timeout_seconds: default_whisper_timeout_seconds(), } } } fn default_whisper_url() -> String { "http://localhost:10300".into() } fn default_whisper_timeout_seconds() -> u64 { 120 } /// Endpoint for the optional NeMo Canary container service. Active only /// when `ASR_BACKEND=canary` is set in `.env`. Both this block and /// `[whisper]` are always loaded; the worker reads only the active one. #[derive(Debug, Clone, Deserialize)] pub struct CanarySettings { #[serde(default = "default_canary_url")] pub url: String, #[serde(default = "default_canary_timeout_seconds")] pub timeout_seconds: u64, } impl Default for CanarySettings { fn default() -> Self { Self { url: default_canary_url(), timeout_seconds: default_canary_timeout_seconds(), } } } fn default_canary_url() -> String { "http://localhost:9002".into() } /// Higher than Whisper's 120s default: Canary's buffered pipeline for /// audio >25s adds chunk-stitching cost (see docs/canary.md §4.1), so /// long dictations need a wider safety margin. fn default_canary_timeout_seconds() -> u64 { 180 } #[derive(Debug, Clone, Deserialize)] pub struct OllamaSettings { #[serde(default = "default_ollama_url")] pub url: String, #[serde(default = "default_ollama_model")] pub model: String, #[serde(default = "default_ollama_keep_alive")] pub keep_alive: u32, } impl Default for OllamaSettings { fn default() -> Self { Self { url: default_ollama_url(), model: default_ollama_model(), keep_alive: default_ollama_keep_alive(), } } } fn default_ollama_url() -> String { "http://localhost:11434".into() } fn default_ollama_model() -> String { "gemma3:4b".into() } fn default_ollama_keep_alive() -> u32 { 0 } #[derive(Debug, Clone, Deserialize)] pub struct LlmSettings { #[serde(default)] pub url: String, #[serde(default)] pub api_key: String, #[serde(default)] pub model: String, #[serde(default)] pub temperature: f32, #[serde(default = "default_llm_timeout_seconds")] pub timeout_seconds: u64, /// Empty values fall back to the code default in [`Settings::load_or_default`] /// — symmetric to the previous env-var behaviour. #[serde(default = "default_llm_system_prompt")] pub system_prompt: String, } impl Default for LlmSettings { fn default() -> Self { Self { url: String::new(), api_key: String::new(), model: String::new(), temperature: 0.0, timeout_seconds: default_llm_timeout_seconds(), system_prompt: default_llm_system_prompt(), } } } fn default_llm_timeout_seconds() -> u64 { 180 } fn default_llm_system_prompt() -> String { crate::analyze::prompt::SYSTEM_PROMPT.to_string() } impl Settings { /// Missing file → defaults + warn. Parse error → panic (startup-fail-fast). /// An empty `llm.system_prompt` field falls back to the code default. pub fn load_or_default(path: &str) -> Self { match std::fs::read_to_string(path) { Ok(content) => { let mut parsed: Settings = toml::from_str(&content) .unwrap_or_else(|e| panic!("Failed to parse {path}: {e}")); if parsed.llm.system_prompt.is_empty() { parsed.llm.system_prompt = default_llm_system_prompt(); } tracing::info!(path, "settings loaded"); parsed } Err(_) => { tracing::warn!(path, "settings file not found — using code defaults"); Self::default() } } } /// `api_key` is intentionally not required — Ollama's OpenAI-compatible /// endpoint accepts unauthenticated requests. pub fn llm_configured(&self) -> bool { !self.llm.url.is_empty() && !self.llm.model.is_empty() } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_full_toml() { let s: Settings = toml::from_str( r#" [whisper] url = "http://h:1" timeout_seconds = 60 [canary] url = "http://h:9" timeout_seconds = 240 [ollama] url = "http://h:2" model = "x" keep_alive = 5 [llm] url = "http://h:3" api_key = "k" model = "m" temperature = 0.5 timeout_seconds = 30 system_prompt = "p" "#, ) .unwrap(); assert_eq!(s.whisper.url, "http://h:1"); assert_eq!(s.whisper.timeout_seconds, 60); assert_eq!(s.canary.url, "http://h:9"); assert_eq!(s.canary.timeout_seconds, 240); assert_eq!(s.ollama.keep_alive, 5); assert_eq!(s.llm.api_key, "k"); assert_eq!(s.llm.system_prompt, "p"); } #[test] fn empty_toml_yields_defaults() { let s: Settings = toml::from_str("").unwrap(); assert_eq!(s.whisper.url, "http://localhost:10300"); assert_eq!(s.whisper.timeout_seconds, 120); assert_eq!(s.canary.url, "http://localhost:9002"); assert_eq!(s.canary.timeout_seconds, 180); assert_eq!(s.ollama.url, "http://localhost:11434"); assert_eq!(s.ollama.model, "gemma3:4b"); assert_eq!(s.llm.timeout_seconds, 180); } #[test] fn partial_toml_fills_in_defaults() { let s: Settings = toml::from_str( r#" [whisper] url = "http://only-whisper" "#, ) .unwrap(); assert_eq!(s.whisper.url, "http://only-whisper"); assert_eq!(s.whisper.timeout_seconds, 120); assert_eq!(s.ollama.url, "http://localhost:11434"); } #[test] fn llm_configured_requires_url_and_model() { let mut s = Settings::default(); assert!(!s.llm_configured()); s.llm.url = "http://x".into(); assert!(!s.llm_configured()); s.llm.model = "m".into(); assert!(s.llm_configured()); } }