diff --git a/server/src/config.rs b/server/src/config.rs index 75de734..c604f73 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -87,15 +87,33 @@ fn load_users(path: &str) -> (Vec, HashMap) { panic!("No users defined in {path}. At least one is required."); } - let api_keys = parsed - .user - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); + let api_keys = validate_and_index_users(&parsed.user) + .unwrap_or_else(|e| panic!("Invalid users file {path}: {e}")); (parsed.user, api_keys) } +/// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on +/// data directories) and duplicate api_keys (ambiguous authentication). +pub fn validate_and_index_users(users: &[User]) -> Result, String> { + let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut api_keys: HashMap = HashMap::new(); + + for u in users { + if !slugs.insert(u.slug.as_str()) { + return Err(format!("duplicate slug: {}", u.slug)); + } + if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) { + return Err(format!( + "duplicate api_key shared by users {existing} and {}", + u.slug + )); + } + } + + Ok(api_keys) +} + /// Read a required environment variable. Panics with a clear message if missing. fn required_env(name: &str) -> String { std::env::var(name) @@ -129,3 +147,39 @@ where Err(_) => default, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn u(slug: &str, api_key: &str) -> User { + User { + slug: slug.into(), + api_key: api_key.into(), + web_password: String::new(), + role: "doctor".into(), + } + } + + #[test] + fn accepts_distinct_users() { + let users = vec![u("a", "k1"), u("b", "k2")]; + let map = validate_and_index_users(&users).unwrap(); + assert_eq!(map.len(), 2); + assert_eq!(map.get("k1").unwrap(), "a"); + } + + #[test] + fn rejects_duplicate_api_keys() { + let users = vec![u("a", "same"), u("b", "same")]; + let err = validate_and_index_users(&users).unwrap_err(); + assert!(err.contains("duplicate api_key"), "got: {err}"); + } + + #[test] + fn rejects_duplicate_slugs() { + let users = vec![u("same", "k1"), u("same", "k2")]; + let err = validate_and_index_users(&users).unwrap_err(); + assert!(err.contains("duplicate slug"), "got: {err}"); + } +} diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 33de349..d27fb0c 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -13,6 +13,7 @@ use crate::error::AppError; struct RecordingView { filename: String, + transcript: Option, } struct CaseView { @@ -170,9 +171,12 @@ async fn scan_recordings(case_dir: &Path) -> Vec { Ok(s) => s, Err(_) => continue, }; - if filename.ends_with(".m4a") { - recordings.push(RecordingView { filename }); + if !filename.ends_with(".m4a") { + continue; } + let transcript_path = case_dir.join(&filename).with_extension("transcript.txt"); + let transcript = tokio::fs::read_to_string(&transcript_path).await.ok(); + recordings.push(RecordingView { filename, transcript }); } recordings.sort_by(|a, b| a.filename.cmp(&b.filename)); diff --git a/server/src/transcribe/whisper.rs b/server/src/transcribe/whisper.rs index 7360c99..2c1cd27 100644 --- a/server/src/transcribe/whisper.rs +++ b/server/src/transcribe/whisper.rs @@ -27,8 +27,10 @@ impl std::error::Error for WhisperError {} /// POST the audio file to whisper-asr-webservice and return the transcript text. /// -/// Uses `output=txt` for a plain-text response and `encode=false` to skip -/// whisper's internal ffmpeg pass — we remux ourselves upstream. +/// `output=txt` → plain-text response. `language=de` pins German so the model +/// skips language detection. We send AAC-in-mp4 and let the service decode it +/// internally (the default `encode=true`) — passing `encode=false` would tell +/// the service "this is already raw PCM", which ours isn't. pub async fn transcribe( client: &reqwest::Client, whisper_url: &str, @@ -48,7 +50,7 @@ pub async fn transcribe( .map_err(WhisperError::Http)?; let form = Form::new().part("audio_file", part); - let url = format!("{}/asr?output=txt&encode=false", whisper_url.trim_end_matches('/')); + let url = format!("{}/asr?output=txt&language=de", whisper_url.trim_end_matches('/')); debug!(%url, "posting to whisper"); let response = client diff --git a/server/templates/cases.html b/server/templates/cases.html index 1fd092f..fa1ffa9 100644 --- a/server/templates/cases.html +++ b/server/templates/cases.html @@ -10,8 +10,11 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 .case small { color: #666; font-family: monospace; } .status-open { border-left: 4px solid #4a90e2; } .status-done { border-left: 4px solid #7ed321; } -.recording { margin: 0.5em 0; display: flex; align-items: center; gap: 1em; } -.recording span { font-family: monospace; font-size: 0.9em; min-width: 20em; } +.recording { margin: 0.5em 0; } +.recording-head { display: flex; align-items: center; gap: 1em; } +.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; } +.transcript { margin: 0.5em 0 0 1em; padding: 0.5em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; } +.pending { margin: 0.5em 0 0 1em; color: #888; font-style: italic; } @@ -25,11 +28,19 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 {{ case.case_id }} {% for rec in case.recordings %}
+
{{ rec.filename }}
+{% match rec.transcript %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +
Transcription pending…
+{% endmatch %} +
{% endfor %} {% endfor %} diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index ee63bf6..d782124 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -58,7 +58,7 @@ async fn whisper_client_posts_multipart_and_returns_text() { Mock::given(method("POST")) .and(path("/asr")) .and(query_param("output", "txt")) - .and(query_param("encode", "false")) + .and(query_param("language", "de")) .respond_with(ResponseTemplate::new(200).set_body_string(expected)) .expect(1) .mount(&server) diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 6fe7027..b271f12 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -93,6 +93,11 @@ async fn web_index_shows_cases() { b"fake audio 2", ) .unwrap(); + std::fs::write( + done_case.join("2026-04-12T09-00-00Z.transcript.txt"), + "Herzkatheter ohne Befund.", + ) + .unwrap(); let app = doctate_server::create_router(config); let response = app @@ -113,6 +118,10 @@ async fn web_index_shows_cases() { assert!(body.contains("dr_test")); assert!(body.contains("