Refactor user config validation and add transcript display

Introduces a dedicated function `validate_and_index_users` to
encapsulate the logic for validating user configurations, checking for
duplicate slugs and API keys. This function returns a `Result` to handle
errors gracefully, and its usage in `load_users` is updated to panic
with a more informative error message.

Additionally, this commit modifies the web routing to fetch and display
audio transcripts. When scanning recordings, it now attempts to read a
corresponding `.transcript.txt` file. If found, the transcript content
is included in the `RecordingView` and rendered in the `cases.html`
template. If the transcript file is not found, a "Transcription pending"
message is displayed.

The `transcribe` function in `whisper.rs` is updated to explicitly set
the language to German (`language=de`), which can improve transcription
accuracy by skipping language detection. The `encode=false` query
parameter has been removed, as the service is designed to handle encoded
audio.

Finally, tests have been added for the new `validate_and_index_users`
function to ensure duplicate slugs and API keys are correctly rejected.
Integration tests in `web_test.rs` have been updated to verify the
rendering of transcripts and the pending status.
This commit is contained in:
2026-04-13 17:08:21 +02:00
parent 13375b8297
commit 1e3cc9574c
6 changed files with 93 additions and 13 deletions
+59 -5
View File
@@ -87,15 +87,33 @@ fn load_users(path: &str) -> (Vec<User>, HashMap<String, String>) {
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<HashMap<String, String>, String> {
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut api_keys: HashMap<String, String> = 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}");
}
}
+6 -2
View File
@@ -13,6 +13,7 @@ use crate::error::AppError;
struct RecordingView {
filename: String,
transcript: Option<String>,
}
struct CaseView {
@@ -170,9 +171,12 @@ async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
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));
+5 -3
View File
@@ -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