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:
+59
-5
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -25,11 +28,19 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
|
||||
<small>{{ case.case_id }}</small>
|
||||
{% for rec in case.recordings %}
|
||||
<div class="recording">
|
||||
<div class="recording-head">
|
||||
<span>{{ rec.filename }}</span>
|
||||
<audio controls preload="none">
|
||||
<source src="/web/audio/{{ case.user_slug }}/{{ case.case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||
</audio>
|
||||
</div>
|
||||
{% match rec.transcript %}
|
||||
{% when Some with (t) %}
|
||||
<div class="transcript">{{ t }}</div>
|
||||
{% when None %}
|
||||
<div class="pending">Transcription pending…</div>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("<audio"));
|
||||
assert!(body.contains("2026-04-13T10-30-00Z.m4a"));
|
||||
// case1 has no transcript → pending placeholder
|
||||
assert!(body.contains("Transcription pending"));
|
||||
// case2 has a transcript → rendered
|
||||
assert!(body.contains("Herzkatheter ohne Befund."));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user