feat(server): split runtime knobs out of .env into settings.toml
.env shrinks to bootstrap values only (port, paths, log, auth, deploy flags). The 14 admin-tunable values (retention, whisper, ollama, llm) move to a new settings.toml with serde(default)-based per-section defaults and an empty-prompt fallback to the code default. settings.toml is gitignored alongside .env; settings.toml.example is the template. This commit fixes the file layout. The server-side wiring (Config shrink, AppState integration, consumer migration) follows separately.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
target/
|
||||
.env
|
||||
users.toml
|
||||
settings.toml
|
||||
*.snap.new
|
||||
repomix-output.xml
|
||||
tmpdata/
|
||||
|
||||
+13
-33
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user