From a510c20e7580f6ac7bf5ad04f8689f5d0b1a5ad0 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 27 Apr 2026 11:48:41 +0200 Subject: [PATCH] refactor(server): plumb Arc through workers and routes Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and gains a sibling Arc in AppState, loaded from settings.toml at startup via Settings::load_or_default. Workers (transcribe, analyze, recovery, auto_trigger) and routes (health, bulk, case_actions, user_web) take settings as a separate parameter or State<> extractor; Config::llm_configured moves to Settings::llm_configured. TestConfig::build_pair() returns (Arc, Arc); the new create_router_with_settings / create_router_and_session_store_with_settings helpers wire both into integration tests that exercise LLM/Whisper/Ollama. The legacy single-Arc create_router falls back to Settings::default() so the 17 non-LLM tests stay untouched. Verified: cargo build clean, clippy --all-targets clean, 318 tests pass. --- server/src/analyze/auto_trigger.rs | 22 +-- server/src/analyze/worker.rs | 32 ++-- server/src/config.rs | 66 +------- server/src/lib.rs | 25 +++ server/src/main.rs | 30 ++-- server/src/routes/bulk.rs | 8 +- server/src/routes/case_actions.rs | 4 +- server/src/routes/health.rs | 10 +- server/src/routes/user_web.rs | 27 ++-- server/src/transcribe/recovery.rs | 10 +- server/src/transcribe/worker.rs | 21 +-- server/tests/analyze_test.rs | 153 +++++++++--------- server/tests/case_page_test.rs | 6 +- server/tests/common/config.rs | 48 ++++-- .../failed_only_case_empty_oneliner_test.rs | 13 +- server/tests/magic_link_test.rs | 1 + server/tests/oneliner_heal_decoupled_test.rs | 22 ++- server/tests/oneliner_override_test.rs | 15 +- server/tests/silent_case_empty_test.rs | 13 +- server/tests/transcribe_test.rs | 18 ++- .../tests/transient_failure_retries_test.rs | 14 +- 21 files changed, 310 insertions(+), 248 deletions(-) diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index 14dc42c..cfe3cfc 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -19,10 +19,10 @@ use tokio::io::AsyncWriteExt; use tracing::warn; use super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; -use crate::config::Config; use crate::events::{self, CaseEventKind, EventSender}; use crate::paths; use crate::routes::case_actions::build_analysis_input; +use crate::settings::Settings; /// Filename of the per-case failure marker. Written by the worker on /// error, deleted on success. Presence with a matching @@ -99,10 +99,10 @@ pub async fn evaluate_case(case_dir: &Path) -> AutoDecision { pub async fn try_enqueue( case_dir: &Path, tx: &AnalyzeSender, - config: &Arc, + settings: &Arc, events_tx: &EventSender, ) -> bool { - if !config.llm_configured() { + if !settings.llm_configured() { return false; } @@ -159,7 +159,7 @@ pub async fn try_enqueue( pub async fn try_enqueue_all_for_user( user_root: &Path, tx: &AnalyzeSender, - config: &Arc, + settings: &Arc, events_tx: &EventSender, ) { let Ok(mut entries) = tokio::fs::read_dir(user_root).await else { @@ -179,7 +179,7 @@ pub async fn try_enqueue_all_for_user( if crate::paths::is_closed(&case_path).await { continue; } - try_enqueue(&case_path, tx, config, events_tx).await; + try_enqueue(&case_path, tx, settings, events_tx).await; } } @@ -466,8 +466,8 @@ mod tests { #[tokio::test] async fn try_enqueue_all_sends_only_eligible_cases() { use crate::analyze::channel as analyze_channel; - use crate::config::Config; use crate::events::channel as events_channel; + use crate::settings::Settings; let user_root = TempDir::new().unwrap(); @@ -515,12 +515,12 @@ mod tests { let (tx, mut rx) = analyze_channel(); let events_tx = events_channel(); - let mut cfg = Config::test_default(); - cfg.llm_url = "http://localhost:9999".into(); - cfg.llm_model = "test".into(); - let cfg = Arc::new(cfg); + let mut settings = Settings::default(); + settings.llm.url = "http://localhost:9999".into(); + settings.llm.model = "test".into(); + let settings = Arc::new(settings); - try_enqueue_all_for_user(user_root.path(), &tx, &cfg, &events_tx).await; + try_enqueue_all_for_user(user_root.path(), &tx, &settings, &events_tx).await; // Drop the sender half so recv closes after draining. drop(tx); diff --git a/server/src/analyze/worker.rs b/server/src/analyze/worker.rs index 1ceca82..7f8ef1b 100644 --- a/server/src/analyze/worker.rs +++ b/server/src/analyze/worker.rs @@ -8,9 +8,9 @@ use tracing::{error, info, warn}; use super::{ ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, auto_trigger, llm, prompt, }; -use crate::config::Config; use crate::events::{self, CaseEventKind, EventSender}; use crate::gazetteer::Gazetteer; +use crate::settings::Settings; use crate::{BusyGuard, WorkerBusy}; /// Consume analyze jobs sequentially. The external LLM tolerates parallelism, @@ -18,18 +18,26 @@ use crate::{BusyGuard, WorkerBusy}; /// provider from bursts. Trivial to lift later via `tokio::spawn(process(..))`. pub async fn run( mut rx: AnalyzeReceiver, - config: Arc, + settings: Arc, client: reqwest::Client, worker_busy: WorkerBusy, vocab: Arc, events_tx: EventSender, ) { info!(vocab_entries = vocab.len(), "Analyze worker started"); - let timeout = Duration::from_secs(config.llm_timeout_seconds); + let timeout = Duration::from_secs(settings.llm.timeout_seconds); while let Some(job) = rx.recv().await { let _guard = BusyGuard::new(worker_busy.clone()); - process(&job.case_dir, &config, &client, &vocab, timeout, &events_tx).await; + process( + &job.case_dir, + &settings, + &client, + &vocab, + timeout, + &events_tx, + ) + .await; } warn!("Analyze worker stopped (channel closed)"); @@ -37,7 +45,7 @@ pub async fn run( async fn process( case_dir: &Path, - config: &Config, + settings: &Settings, client: &reqwest::Client, vocab: &Gazetteer, timeout: Duration, @@ -106,16 +114,16 @@ async fn process( // document write below, the recovery scan will re-enqueue this job and // the call will be made again. Accepted cost — rare event, small // per-call price. Response-caching in a `.tmp` file would prevent it. - let settings = llm::LlmSettings { - base_url: &config.llm_url, - api_key: &config.llm_api_key, - model: &config.llm_model, - temperature: config.llm_temperature, + let llm_settings = llm::LlmSettings { + base_url: &settings.llm.url, + api_key: &settings.llm.api_key, + model: &settings.llm.model, + temperature: settings.llm.temperature, }; let document = match llm::chat_once( client, - &settings, - &config.llm_system_prompt, + &llm_settings, + &settings.llm.system_prompt, &user_content, timeout, ) diff --git a/server/src/config.rs b/server/src/config.rs index 43a737e..bbe9108 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -75,6 +75,9 @@ struct UsersFile { user: Vec, } +/// Bootstrap config loaded once from `.env` at startup. Runtime-tunable +/// values (retention, whisper/ollama/llm endpoints, prompts) live in +/// `Settings` (loaded from `settings.toml`). pub struct Config { // Phase 1 — required pub server_port: u16, @@ -86,24 +89,6 @@ pub struct Config { pub api_keys: HashMap, // api_key_value → slug // Phase 2+ — optional with defaults - pub retention_audio_days: u32, - pub retention_transcript_days: u32, - pub retention_document_days: u32, - pub whisper_url: String, - pub whisper_timeout_seconds: u64, - pub ollama_url: String, - pub ollama_model: String, - pub ollama_keep_alive: u32, - pub llm_url: String, - pub llm_api_key: String, - pub llm_model: String, - pub llm_temperature: f32, - pub llm_timeout_seconds: u64, - /// System prompt sent to the analysis LLM. Defaults to - /// `crate::analyze::prompt::SYSTEM_PROMPT` if `LLM_SYSTEM_PROMPT` is unset - /// or empty. Runtime-configurable so admins can iterate and use the - /// "Neu analysieren" button to re-run cases. - pub llm_system_prompt: String, /// Directory containing gazetteer `*.txt` files (anatomy, substances, /// medications). Loaded once at startup; a missing directory is a /// non-fatal WARN — analysis runs without proper-name correction. @@ -135,27 +120,6 @@ impl Config { users, api_keys, - retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30), - retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30), - retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0), - whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"), - whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120), - ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"), - ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"), - ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0), - llm_url: optional_env("LLM_URL", ""), - llm_api_key: optional_env("LLM_API_KEY", ""), - llm_model: optional_env("LLM_MODEL", ""), - llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0), - llm_timeout_seconds: optional_env_parsed("LLM_TIMEOUT_SECONDS", 180), - llm_system_prompt: { - let v = optional_env("LLM_SYSTEM_PROMPT", ""); - if v.is_empty() { - crate::analyze::prompt::SYSTEM_PROMPT.to_string() - } else { - v - } - }, vocab_dir: PathBuf::from(optional_env("VOCAB_DIR", "./vocab")), hunspell_dict_path: PathBuf::from(optional_env( "HUNSPELL_DICT", @@ -166,16 +130,6 @@ impl Config { } } - /// True iff the external analysis LLM is configured. `llm_api_key` is - /// intentionally **not** required — Ollama's OpenAI-compatible endpoint - /// at `/v1/chat/completions` accepts unauthenticated requests and is a - /// supported deployment target. For hosted providers (Ionos, OpenAI) the - /// key remains necessary in practice; the provider will reject with 401 - /// if it is missing, which is correct feedback. - pub fn llm_configured(&self) -> bool { - !self.llm_url.is_empty() && !self.llm_model.is_empty() - } - /// Sane defaults for integration tests. Not a `Default` impl on purpose: /// production code must go through `from_env()`, and `Default::default()` /// carries an implicit "safe fallback" connotation this value does not @@ -199,20 +153,6 @@ impl Config { log_max_days: 90, users: Vec::new(), api_keys: HashMap::new(), - retention_audio_days: 30, - retention_transcript_days: 30, - retention_document_days: 0, - whisper_url: "http://localhost:10300".into(), - whisper_timeout_seconds: 120, - ollama_url: "http://localhost:11434".into(), - ollama_model: "gemma3:4b".into(), - ollama_keep_alive: 0, - llm_url: String::new(), - llm_api_key: String::new(), - llm_model: String::new(), - llm_temperature: 0.0, - llm_timeout_seconds: 180, - llm_system_prompt: crate::analyze::prompt::SYSTEM_PROMPT.to_string(), vocab_dir: PathBuf::new(), hunspell_dict_path: PathBuf::new(), session_timeout_hours: 8, diff --git a/server/src/lib.rs b/server/src/lib.rs index 0241c37..24e074c 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -12,6 +12,7 @@ pub mod oneliner_locks; pub mod paths; pub mod retention; pub mod routes; +pub mod settings; pub mod transcribe; pub mod web_session; @@ -28,6 +29,7 @@ use config::Config; use gazetteer::Gazetteer; use magic_link::MagicLinkStore; use oneliner_locks::OnelinerLocks; +use settings::Settings; use transcribe::TranscribeSender; use web_session::SessionStore; @@ -88,6 +90,7 @@ pub struct PipelineState { #[derive(Clone)] pub struct AppState { pub config: Arc, + pub settings: Arc, pub transcribe_tx: TranscribeSender, pub analyze_tx: AnalyzeSender, pub session_store: SessionStore, @@ -107,6 +110,12 @@ impl FromRef for Arc { } } +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Self { + state.settings.clone() + } +} + impl FromRef for TranscribeSender { fn from_ref(state: &AppState) -> Self { state.transcribe_tx.clone() @@ -199,11 +208,27 @@ pub fn create_router(config: Arc) -> Router { /// shared with the router (`Arc`), so any session created via `/web/login` /// or `/web/magic` becomes observable through it. pub fn create_router_and_session_store(config: Arc) -> (Router, SessionStore) { + create_router_and_session_store_with_settings(config, Arc::new(Settings::default())) +} + +/// Variant of [`create_router`] that takes both `Config` and `Settings`. +/// Use from tests that exercise Bucket-B paths (LLM, Whisper, Ollama URLs); +/// otherwise the [`create_router`] convenience with default Settings is enough. +pub fn create_router_with_settings(config: Arc, settings: Arc) -> Router { + create_router_and_session_store_with_settings(config, settings).0 +} + +/// Variant of [`create_router_and_session_store`] that takes both `Config` and `Settings`. +pub fn create_router_and_session_store_with_settings( + config: Arc, + settings: Arc, +) -> (Router, SessionStore) { let (transcribe_tx, _transcribe_rx) = transcribe::channel(); let (analyze_tx, _analyze_rx) = analyze::channel(); let session_store = web_session::new_store(); let router = create_router_with_state(AppState { config, + settings, transcribe_tx, analyze_tx, session_store: session_store.clone(), diff --git a/server/src/main.rs b/server/src/main.rs index 125dc57..234bc90 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,6 +13,7 @@ use doctate_server::analyze; use doctate_server::config::Config; use doctate_server::events; use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; +use doctate_server::settings::Settings; use doctate_server::transcribe; #[tokio::main] @@ -20,6 +21,9 @@ async fn main() { dotenvy::dotenv().ok(); let config = Arc::new(Config::from_env()); + let settings_path = std::env::var("SETTINGS_FILE").unwrap_or_else(|_| "settings.toml".into()); + let settings = Arc::new(Settings::load_or_default(&settings_path)); + // Logging: stdout + daily rotating log files. let env_filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info")); @@ -43,23 +47,17 @@ async fn main() { .init(); // Surface configuration gaps that silently disable features. - if !config.llm_configured() { + if !settings.llm_configured() { warn!( "LLM not configured — 'Fall abschließen' is disabled. \ - Set LLM_URL, LLM_API_KEY and LLM_MODEL in .env to enable." + Set [llm].url and [llm].model in settings.toml to enable." ); } - { - let custom = std::env::var("LLM_SYSTEM_PROMPT") - .map(|v| !v.is_empty()) - .unwrap_or(false); - info!( - prompt_source = if custom { "env" } else { "default" }, - prompt_chars = config.llm_system_prompt.chars().count(), - "system prompt loaded" - ); - } + info!( + prompt_chars = settings.llm.system_prompt.chars().count(), + "system prompt loaded" + ); // Shared HTTP client for both downstream pipelines. let http_client = reqwest::Client::builder() @@ -135,6 +133,7 @@ async fn main() { tokio::spawn(transcribe::worker::run( transcribe_rx, config.clone(), + settings.clone(), http_client.clone(), transcribe_busy.clone(), vocab.clone(), @@ -145,7 +144,7 @@ async fn main() { let tx = transcribe_tx.clone(); let data_path = config.data_path.clone(); let client = http_client.clone(); - let cfg = config.clone(); + let settings = settings.clone(); let vocab = vocab.clone(); let events = events_tx.clone(); let heal_busy = oneliner_heal_busy.clone(); @@ -158,7 +157,7 @@ async fn main() { // page-load does not spawn a second parallel regen loop. let _guard = doctate_server::BusyGuard::new(heal_busy); transcribe::recovery::regenerate_missing_oneliners( - &data_path, &client, &cfg, &vocab, &events, &locks, + &data_path, &client, &settings, &vocab, &events, &locks, ) .await; }); @@ -170,7 +169,7 @@ async fn main() { std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); tokio::spawn(analyze::worker::run( analyze_rx, - config.clone(), + settings.clone(), http_client.clone(), analyze_busy.clone(), vocab.clone(), @@ -186,6 +185,7 @@ async fn main() { let state = AppState { config: config.clone(), + settings: settings.clone(), transcribe_tx, analyze_tx, session_store: doctate_server::web_session::new_store(), diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index d70a8fd..6ca4e87 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -20,6 +20,7 @@ use crate::routes::case_actions::{ build_analysis_input, reset_case_artefacts, write_input_create_new, }; use crate::routes::user_web::locate_case; +use crate::settings::Settings; #[derive(Debug, Deserialize)] pub struct BulkForm { @@ -44,6 +45,7 @@ impl HasCsrfToken for BulkForm { pub async fn handle_bulk_action( user: AuthenticatedWebUser, State(config): State>, + State(settings): State>, State(analyze_tx): State, State(events_tx): State, State(locks): State, @@ -70,7 +72,7 @@ pub async fn handle_bulk_action( match action { BulkAction::Analyze => { bulk_analyze( - &config, + &settings, &analyze_tx, &events_tx, &user.slug, @@ -89,14 +91,14 @@ pub async fn handle_bulk_action( } async fn bulk_analyze( - config: &Config, + settings: &Settings, analyze_tx: &AnalyzeSender, events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { - if !config.llm_configured() { + if !settings.llm_configured() { warn!(slug = %slug, "bulk-analyze: LLM not configured, skipping all"); return; } diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 8f342a3..6d40979 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -30,6 +30,7 @@ use crate::paths::{ }; use crate::routes::user_web::locate_case_or_404; use crate::routes::web::validate_filename; +use crate::settings::Settings; /// POST /web/cases/{case_id}/analyze /// @@ -41,12 +42,13 @@ use crate::routes::web::validate_filename; pub async fn handle_analyze_case( user: AuthenticatedWebUser, State(config): State>, + State(settings): State>, State(analyze_tx): State, State(events_tx): State, CaseIdPath(case_id): CaseIdPath, CsrfForm(form): CsrfForm, ) -> Result { - if !config.llm_configured() { + if !settings.llm_configured() { return Err(AppError::ServiceUnavailable( "LLM-Analyse nicht konfiguriert".into(), )); diff --git a/server/src/routes/health.rs b/server/src/routes/health.rs index a27fcf0..36829f6 100644 --- a/server/src/routes/health.rs +++ b/server/src/routes/health.rs @@ -5,14 +5,18 @@ use axum::extract::State; use serde_json::{Value, json}; use crate::config::Config; +use crate::settings::Settings; -pub async fn handle_health(State(config): State>) -> Json { +pub async fn handle_health( + State(config): State>, + State(settings): State>, +) -> Json { Json(json!({ "status": "ok", "port": config.server_port, "data_path": config.data_path.to_string_lossy(), "users": config.users.iter().map(|u| &u.slug).collect::>(), - "whisper_url": config.whisper_url, - "ollama_url": config.ollama_url, + "whisper_url": settings.whisper.url, + "ollama_url": settings.ollama.url, })) } diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index b2b404f..17bcd35 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -25,6 +25,7 @@ use crate::gazetteer::Gazetteer; use crate::paths; use crate::routes::case_actions::read_document; use crate::routes::web::{RecordingView, scan_recordings}; +use crate::settings::Settings; use crate::transcribe::recovery as transcribe_recovery; use crate::{BusyGuard, PipelineState}; @@ -429,7 +430,7 @@ impl PipelineState { user_root: &Path, slug: &str, http_client: &reqwest::Client, - config: &Arc, + settings: &Arc, vocab: &Arc, events_tx: &EventSender, ) { @@ -456,7 +457,7 @@ impl PipelineState { let user_root = user_root.to_path_buf(); let slug_owned = slug.to_owned(); let http_client = http_client.clone(); - let config = Arc::clone(config); + let settings = Arc::clone(settings); let vocab = Arc::clone(vocab); let events_tx = events_tx.clone(); let busy = Arc::clone(&self.oneliner_heal_busy.0); @@ -472,7 +473,7 @@ impl PipelineState { &user_root, &slug_owned, &http_client, - &config, + &settings, &vocab, &events_tx, &locks, @@ -484,9 +485,11 @@ impl PipelineState { } } +#[allow(clippy::too_many_arguments)] pub async fn handle_my_cases( user: AuthenticatedWebUser, State(config): State>, + State(settings): State>, State(pipeline): State, State(events_tx): State, State(http_client): State, @@ -499,7 +502,7 @@ pub async fn handle_my_cases( &user_root, &user.slug, &http_client, - &config, + &settings, &vocab, &events_tx, ) @@ -508,7 +511,7 @@ pub async fn handle_my_cases( // render. The common case is "nothing to do" and costs a handful of // stat-calls per case; actual enqueues happen only when pre-conditions // flip (new transcripts in, stale document, ...). - auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &config, &events_tx) + auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &settings, &events_tx) .await; // Lazy retention sweep: at most one disk scan per /web/cases visit, @@ -531,6 +534,7 @@ pub async fn handle_my_cases( busy, show_closed, retention.auto_delete_days, + settings.llm_configured(), ) .await; let total = cases.len(); @@ -579,6 +583,7 @@ pub async fn handle_my_cases( pub async fn handle_case_page( user: AuthenticatedWebUser, State(config): State>, + State(settings): State>, State(pipeline): State, State(events_tx): State, State(http_client): State, @@ -592,7 +597,7 @@ pub async fn handle_case_page( &user_root, &user.slug, &http_client, - &config, + &settings, &vocab, &events_tx, ) @@ -619,7 +624,7 @@ pub async fn handle_case_page( // Auto-analysis trigger for deep-link navigation: same evaluation as // the list view. Document render below still wins the race if the // worker happens to finish synchronously. - auto_trigger::try_enqueue(&case_dir, &pipeline.analyze_tx, &config, &events_tx).await; + auto_trigger::try_enqueue(&case_dir, &pipeline.analyze_tx, &settings, &events_tx).await; let case_id_str = case_id.to_string(); info!(slug = %user.slug, case_id = %case_id, "case page viewed"); @@ -634,7 +639,7 @@ pub async fn handle_case_page( let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await; let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire); - let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; + let flags = compute_flags(&case_dir, &recordings, settings.llm_configured(), a_busy).await; let recordings_count = recordings.len(); let transcribed_count = recordings @@ -681,9 +686,11 @@ pub async fn handle_case_page( /// /// Read-only sub-page showing all `.m4a` + transcript pairs for the case. /// Useful for power-users/admins; not part of the day-to-day workflow. +#[allow(clippy::too_many_arguments)] pub async fn handle_case_recordings( user: AuthenticatedWebUser, State(config): State>, + State(settings): State>, State(pipeline): State, State(events_tx): State, State(http_client): State, @@ -696,7 +703,7 @@ pub async fn handle_case_recordings( &user_root, &user.slug, &http_client, - &config, + &settings, &vocab, &events_tx, ) @@ -810,9 +817,9 @@ async fn scan_user_cases( worker_busy: bool, include_closed: bool, auto_delete_days: u32, + llm_configured: bool, ) -> Vec { let user_root = config.data_path.join(slug); - let llm_configured = config.llm_configured(); let mut cases = Vec::new(); let now = OffsetDateTime::now_utc(); diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index 4d9305f..5c39742 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -4,11 +4,11 @@ use doctate_common::oneliners::OnelinerState; use tracing::{info, warn}; use super::{TranscribeJob, TranscribeSender}; -use crate::config::Config; use crate::events::EventSender; use crate::gazetteer::Gazetteer; use crate::oneliner_locks::OnelinerLocks; use crate::paths; +use crate::settings::Settings; /// Walk `data_path/*/` and enqueue every recording pending transcription for /// every user. Intended for startup; the same primitive backs the per-user @@ -204,7 +204,7 @@ async fn has_any_transcript(case_dir: &Path) -> bool { pub async fn regenerate_missing_oneliners( data_path: &Path, client: &reqwest::Client, - config: &Config, + settings: &Settings, vocab: &Gazetteer, events_tx: &EventSender, locks: &OnelinerLocks, @@ -215,7 +215,7 @@ pub async fn regenerate_missing_oneliners( } info!(count = cases.len(), "Regenerating missing oneliners"); for (case_dir, slug) in cases { - super::worker::update_oneliner(&case_dir, &slug, client, config, vocab, events_tx, locks) + super::worker::update_oneliner(&case_dir, &slug, client, settings, vocab, events_tx, locks) .await; } } @@ -228,7 +228,7 @@ pub async fn regenerate_missing_oneliners_for_user( user_root: &Path, slug: &str, client: &reqwest::Client, - config: &Config, + settings: &Settings, vocab: &Gazetteer, events_tx: &EventSender, locks: &OnelinerLocks, @@ -238,7 +238,7 @@ pub async fn regenerate_missing_oneliners_for_user( return; } for case_dir in cases { - super::worker::update_oneliner(&case_dir, slug, client, config, vocab, events_tx, locks) + super::worker::update_oneliner(&case_dir, slug, client, settings, vocab, events_tx, locks) .await; } } diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 6cad094..ce533ab 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -14,15 +14,18 @@ use crate::events::{self, CaseEventKind, EventSender}; use crate::gazetteer::Gazetteer; use crate::oneliner_locks::OnelinerLocks; use crate::paths; +use crate::settings::Settings; use crate::{BusyGuard, WorkerBusy}; const ONELINER_TIMEOUT: Duration = Duration::from_secs(60); /// Consume transcription jobs sequentially. Whisper holds the GPU, so parallelism /// here would only cause contention. One job in flight at a time. +#[allow(clippy::too_many_arguments)] pub async fn run( mut rx: TranscribeReceiver, config: Arc, + settings: Arc, client: reqwest::Client, worker_busy: WorkerBusy, vocab: Arc, @@ -30,7 +33,7 @@ pub async fn run( oneliner_locks: OnelinerLocks, ) { info!("Transcription worker started"); - let timeout = Duration::from_secs(config.whisper_timeout_seconds); + let timeout = Duration::from_secs(settings.whisper.timeout_seconds); while let Some(job) = rx.recv().await { let _guard = BusyGuard::new(worker_busy.clone()); @@ -44,7 +47,7 @@ pub async fn run( // Look up per-user Whisper settings live from the shared config so that // edits to users.toml (on next restart) reach in-flight jobs naturally. // Unknown slug → empty settings (service falls back to language=de). - let settings: WhisperUserSettings = config + let user_whisper: WhisperUserSettings = config .users .iter() .find(|u| u.slug == job.user_slug) @@ -70,10 +73,10 @@ pub async fn run( let text = match whisper::transcribe( &client, - &config.whisper_url, + &settings.whisper.url, remuxed.path(), timeout, - &settings, + &user_whisper, ) .await { @@ -139,7 +142,7 @@ pub async fn run( case_dir, &job.user_slug, &client, - &config, + &settings, &vocab, &events_tx, &oneliner_locks, @@ -220,7 +223,7 @@ pub async fn update_oneliner( case_dir: &Path, user_slug: &str, client: &reqwest::Client, - config: &Config, + settings: &Settings, vocab: &Gazetteer, events_tx: &EventSender, locks: &OnelinerLocks, @@ -302,9 +305,9 @@ pub async fn update_oneliner( // during the call. let state = match ollama::generate_oneliner( client, - &config.ollama_url, - &config.ollama_model, - config.ollama_keep_alive, + &settings.ollama.url, + &settings.ollama.model, + settings.ollama.keep_alive, &transcript, ONELINER_TIMEOUT, ) diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index ea4452c..e58be73 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -23,27 +23,32 @@ use common::{ // Fixture helpers // --------------------------------------------------------------------- -fn cfg_llm(label: &'static str) -> std::sync::Arc { +type CfgPair = ( + std::sync::Arc, + std::sync::Arc, +); + +fn cfg_llm(label: &'static str) -> CfgPair { TestConfig::new() .with_label(label) .with_user(test_user("dr_a")) .with_llm("http://unused") - .build() + .build_pair() } -fn cfg_llm_admin(label: &'static str) -> std::sync::Arc { +fn cfg_llm_admin(label: &'static str) -> CfgPair { TestConfig::new() .with_label(label) .with_user(test_admin("dr_a")) .with_llm("http://unused") - .build() + .build_pair() } -fn cfg_without_llm(label: &'static str) -> std::sync::Arc { +fn cfg_without_llm(label: &'static str) -> CfgPair { TestConfig::new() .with_label(label) .with_user(test_user("dr_a")) - .build() + .build_pair() } // --------------------------------------------------------------------- @@ -52,8 +57,8 @@ fn cfg_without_llm(label: &'static str) -> std::sync::Arc Arc { + self.build_pair().0 + } + + /// Materialize both `Config` and `Settings`. Use when a test exercises + /// LLM, Whisper or Ollama paths and needs the builder-set values to + /// reach the worker. + pub fn build_pair(self) -> (Arc, Arc) { let data_path = self.data_path.unwrap_or_else(|| unique_tmpdir(self.label)); let api_keys: HashMap = self .users @@ -136,21 +144,33 @@ impl TestConfig { .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); - let defaults = Config::test_default(); - Arc::new(Config { + let config = Arc::new(Config { data_path, users: self.users, api_keys, - llm_url: self.llm_url.unwrap_or(defaults.llm_url), - llm_api_key: self.llm_api_key.unwrap_or(defaults.llm_api_key), - llm_model: self.llm_model.unwrap_or(defaults.llm_model), - whisper_url: self.whisper_url.unwrap_or(defaults.whisper_url), - whisper_timeout_seconds: self - .whisper_timeout_seconds - .unwrap_or(defaults.whisper_timeout_seconds), - ollama_url: self.ollama_url.unwrap_or(defaults.ollama_url), ..Config::test_default() - }) + }); + + let mut settings = Settings::default(); + if let Some(v) = self.llm_url { + settings.llm.url = v; + } + if let Some(v) = self.llm_api_key { + settings.llm.api_key = v; + } + if let Some(v) = self.llm_model { + settings.llm.model = v; + } + if let Some(v) = self.whisper_url { + settings.whisper.url = v; + } + if let Some(v) = self.whisper_timeout_seconds { + settings.whisper.timeout_seconds = v; + } + if let Some(v) = self.ollama_url { + settings.ollama.url = v; + } + (config, Arc::new(settings)) } } diff --git a/server/tests/failed_only_case_empty_oneliner_test.rs b/server/tests/failed_only_case_empty_oneliner_test.rs index cf362b6..4f8c970 100644 --- a/server/tests/failed_only_case_empty_oneliner_test.rs +++ b/server/tests/failed_only_case_empty_oneliner_test.rs @@ -53,10 +53,10 @@ async fn failed_only_case_settles_to_empty_without_llm_call() { .mount(&mock) .await; - let config = TestConfig::new() + let (_config, settings) = TestConfig::new() .with_data_path(data.path().to_path_buf()) .with_ollama(mock.uri()) - .build(); + .build_pair(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); @@ -75,7 +75,14 @@ async fn failed_only_case_settles_to_empty_without_llm_call() { }; pipeline - .heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx) + .heal_orphans_if_idle( + &user_root, + slug, + &http_client, + &settings, + &vocab, + &events_tx, + ) .await; // Heal spawn drops the busy flag on completion — poll until idle. diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index 388696f..beaf5e5 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -59,6 +59,7 @@ fn build_state_with_stores() -> AppState { let (analyze_tx, _analyze_rx) = analyze::channel(); AppState { config: cfg, + settings: Arc::new(doctate_server::settings::Settings::default()), transcribe_tx, analyze_tx, session_store: web_session::new_store(), diff --git a/server/tests/oneliner_heal_decoupled_test.rs b/server/tests/oneliner_heal_decoupled_test.rs index f5deae4..7fb7803 100644 --- a/server/tests/oneliner_heal_decoupled_test.rs +++ b/server/tests/oneliner_heal_decoupled_test.rs @@ -71,10 +71,10 @@ async fn heal_orphans_returns_fast_and_dedups_parallel_calls() { .mount(&mock) .await; - let config = TestConfig::new() + let (_config, settings) = TestConfig::new() .with_data_path(data.path().to_path_buf()) .with_ollama(mock.uri()) - .build(); + .build_pair(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); @@ -99,10 +99,24 @@ async fn heal_orphans_returns_fast_and_dedups_parallel_calls() { let before = Instant::now(); timeout(Duration::from_millis(150), async { pipeline - .heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx) + .heal_orphans_if_idle( + &user_root, + slug, + &http_client, + &settings, + &vocab, + &events_tx, + ) .await; pipeline - .heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx) + .heal_orphans_if_idle( + &user_root, + slug, + &http_client, + &settings, + &vocab, + &events_tx, + ) .await; }) .await diff --git a/server/tests/oneliner_override_test.rs b/server/tests/oneliner_override_test.rs index 23ed6eb..1cf63ec 100644 --- a/server/tests/oneliner_override_test.rs +++ b/server/tests/oneliner_override_test.rs @@ -72,6 +72,7 @@ fn build_app_with_events_tx( let (analyze_tx, _analyze_rx) = analyze::channel(); AppState { config: cfg, + settings: Arc::new(doctate_server::settings::Settings::default()), transcribe_tx, analyze_tx, session_store: web_session::new_store(), @@ -230,11 +231,11 @@ async fn early_latch_skips_llm_call_when_manual_set() { .mount(&mock) .await; - let cfg = TestConfig::new() + let (cfg, settings) = TestConfig::new() .with_label("oneliner-override-early-latch") .with_user(test_user(SLUG)) .with_ollama(mock.uri()) - .build(); + .build_pair(); let case_id = Uuid::new_v4().to_string(); let case_dir = seed_case(&cfg.data_path, SLUG, &case_id); @@ -263,7 +264,7 @@ async fn early_latch_skips_llm_call_when_manual_set() { &case_dir, SLUG, &reqwest::Client::new(), - &cfg, + &settings, &vocab, &events_tx, &locks, @@ -305,11 +306,11 @@ async fn re_check_drops_llm_result_when_manual_appears_during_regen() { .mount(&mock) .await; - let cfg = TestConfig::new() + let (cfg, settings) = TestConfig::new() .with_label("oneliner-override-race") .with_user(test_user(SLUG)) .with_ollama(mock.uri()) - .build(); + .build_pair(); let case_id = Uuid::new_v4().to_string(); let case_dir = seed_case(&cfg.data_path, SLUG, &case_id); @@ -326,7 +327,7 @@ async fn re_check_drops_llm_result_when_manual_appears_during_regen() { // Spawn the regen — it will wait ~500ms inside Mock-Ollama before // reaching the re-check-under-lock branch. - let regen_cfg = cfg.clone(); + let regen_settings = settings.clone(); let regen_locks = locks.clone(); let regen_events = events_tx.clone(); let regen_dir = case_dir.clone(); @@ -336,7 +337,7 @@ async fn re_check_drops_llm_result_when_manual_appears_during_regen() { ®en_dir, SLUG, &reqwest::Client::new(), - ®en_cfg, + ®en_settings, &vocab, ®en_events, ®en_locks, diff --git a/server/tests/silent_case_empty_test.rs b/server/tests/silent_case_empty_test.rs index c534425..b3c31fd 100644 --- a/server/tests/silent_case_empty_test.rs +++ b/server/tests/silent_case_empty_test.rs @@ -55,10 +55,10 @@ async fn silent_only_case_settles_to_empty_without_llm_call() { .mount(&mock) .await; - let config = TestConfig::new() + let (_config, settings) = TestConfig::new() .with_data_path(data.path().to_path_buf()) .with_ollama(mock.uri()) - .build(); + .build_pair(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); @@ -77,7 +77,14 @@ async fn silent_only_case_settles_to_empty_without_llm_call() { }; pipeline - .heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx) + .heal_orphans_if_idle( + &user_root, + slug, + &http_client, + &settings, + &vocab, + &events_tx, + ) .await; // Heal spawn drops the busy flag on completion — poll until idle. diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 72d499b..78fa411 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -260,7 +260,12 @@ async fn recovery_enqueues_only_pending_recordings() { // -------------------- Worker: failure marker -------------------- -fn config_with_whisper(whisper_url: String) -> std::sync::Arc { +fn config_with_whisper( + whisper_url: String, +) -> ( + std::sync::Arc, + std::sync::Arc, +) { // Ollama URL is irrelevant — worker never reaches it in the failure path. // Port 1 is effectively unreachable, matching the historical intent. TestConfig::new() @@ -268,7 +273,7 @@ fn config_with_whisper(whisper_url: String) -> std::sync::Arc