diff --git a/.gitignore b/.gitignore index 8524937..b7afe95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ .env users.toml +settings.toml *.snap.new repomix-output.xml tmpdata/ diff --git a/server/.env.example b/server/.env.example index d00e459..17cff46 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,42 +1,22 @@ -# === Required (Phase 1) === +# Doctate operator config — bootstrap, logging, auth, deploy topology. +# Admin-tunable values (retention, providers, prompts) live in settings.toml +# (see settings.toml.example for the template). +# Server bootstrap SERVER_PORT=3000 DATA_PATH=/data USERS_FILE=users.toml + +# Logging LOG_LEVEL=info LOG_PATH=/var/log/recorder LOG_MAX_DAYS=90 -# === Optional (Phase 2+) === - -# Retention -RETENTION_AUDIO_DAYS=30 -RETENTION_TRANSCRIPT_DAYS=30 -RETENTION_DOCUMENT_DAYS=0 - -# faster-whisper -WHISPER_URL=http://localhost:10300 -WHISPER_TIMEOUT_SECONDS=120 - -# Ollama -OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=gemma3:4b -OLLAMA_KEEP_ALIVE=0 - -# LLM provider (Ionos) -LLM_URL=https://openai.inference.de-txl.ionos.com -LLM_API_KEY= -LLM_MODEL= -LLM_TEMPERATURE=0 -LLM_TIMEOUT_SECONDS=180 -# Optional override of the consolidation system prompt. If unset or empty, a default is used. -# Single line — escape newlines with \n if your shell preserves them, or put the prompt on one logical line. -LLM_SYSTEM_PROMPT="Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, nichts interpretieren, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz." - -# Gazetteer (pre-LLM proper-name correction) -# Directory with *.txt files, one entry per line. Blank and #-comment lines -# are skipped. Missing directory is non-fatal — analysis runs without hints. -VOCAB_DIR=./vocab - -# Session +# Auth SESSION_TIMEOUT_HOURS=8 +# Set to false for plain-HTTP local dev; browsers refuse Secure cookies on http:// +COOKIE_SECURE=true + +# Optional filesystem resources (uncomment if used) +# VOCAB_DIR=./vocab +# HUNSPELL_DICT=/usr/share/hunspell/de_DE.dic diff --git a/server/settings.toml.example b/server/settings.toml.example new file mode 100644 index 0000000..ae17465 --- /dev/null +++ b/server/settings.toml.example @@ -0,0 +1,46 @@ +# Doctate runtime settings — admin-editable via WebUI. +# Bootstrap values (port, data paths, log paths, auth) live in .env. +# Copy to settings.toml and adjust per deployment. + +[retention] +audio_days = 30 +transcript_days = 30 +document_days = 0 + +[whisper] +url = "http://localhost:9000" +timeout_seconds = 120 + +[ollama] +url = "http://localhost:11434" +model = "gemma3:4b" +keep_alive = 0 + +# LLM provider: swap url/api_key/model between blocks to switch providers. +# Only one [llm] block may be active at a time — comment the others. + +# Example: Ionos hosted +[llm] +url = "https://openai.inference.de-txl.ionos.com" +api_key = "" +model = "" +temperature = 0 +timeout_seconds = 180 +# Single line or TOML triple-string for multi-line prompts. +system_prompt = """ +Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte +zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts +hinzufügen, nichts interpretieren, keine Diagnose, keine Arztbrief-Struktur. +Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge +und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. +Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein +Abschlusssatz. +""" + +# Example: Ollama local +# [llm] +# url = "http://localhost:11434" +# api_key = "" +# model = "SimonPu/gpt-oss:20b_Q4_K_M" +# temperature = 0 +# timeout_seconds = 300 diff --git a/server/src/settings.rs b/server/src/settings.rs new file mode 100644 index 0000000..dd79d4d --- /dev/null +++ b/server/src/settings.rs @@ -0,0 +1,238 @@ +//! 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 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 +} + +#[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 + +[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.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.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()); + } +}