Files
doctate/server/tests/transcribe_test.rs
T
Brummel e64204b757 Feat: Add gazetteer normalization to transcription
This commit integrates the Gazetteer into the transcription pipeline to
normalize terminology.

The Gazetteer, loaded at startup, is now passed to the transcription
worker. Before persisting the transcription text, it is processed
through the Gazetteer's `replace` method, ensuring standardized
terminology. This normalization is also applied to the generated
"oneliner" text.

Additionally, the `regenerate_missing_oneliners` function in the
recovery module has been updated to accept and utilize the Gazetteer,
ensuring that regenerated oneliners also benefit from terminology
normalization.

A new integration test, `transcribe_worker_normalizes_whisper_output`,
has been added to verify that the Whisper output is correctly normalized
by the Gazetteer before being saved to the transcript file.
2026-04-16 20:43:04 +02:00

454 lines
15 KiB
Rust

use std::path::PathBuf;
use std::time::Duration;
use std::sync::Arc;
use doctate_server::config::{Config, User, WhisperUserSettings};
use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError};
use doctate_server::transcribe::recovery::scan_and_enqueue;
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
use serde_json::json;
use wiremock::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[tokio::test]
async fn ffmpeg_remux_produces_valid_output() {
let input = fixture("sample.m4a");
assert!(input.exists(), "fixture missing: {}", input.display());
let output = remux_faststart(&input)
.await
.expect("remux failed");
let meta = std::fs::metadata(output.path()).expect("output missing");
assert!(meta.len() > 0, "output file is empty");
// Sanity check: ffprobe should still recognize it as an m4a/mp4 container.
let probe = std::process::Command::new("ffprobe")
.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-show_entries")
.arg("format=format_name")
.arg("-of")
.arg("default=nw=1:nk=1")
.arg(output.path())
.output()
.expect("ffprobe spawn failed");
assert!(probe.status.success(), "ffprobe failed");
let fmt = String::from_utf8_lossy(&probe.stdout);
assert!(fmt.contains("mp4") || fmt.contains("m4a"), "unexpected format: {fmt}");
}
#[tokio::test]
async fn ffmpeg_remux_fails_on_missing_input() {
let input = PathBuf::from("/nonexistent/does-not-exist.m4a");
let result = remux_faststart(&input).await;
assert!(result.is_err(), "expected error for missing input");
}
#[tokio::test]
async fn whisper_client_posts_multipart_and_returns_text() {
let server = MockServer::start().await;
let expected = "Hallo Welt, das ist ein Test.";
Mock::given(method("POST"))
.and(path("/asr"))
.and(query_param("output", "txt"))
.and(query_param("language", "de"))
.respond_with(ResponseTemplate::new(200).set_body_string(expected))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let text = transcribe(
&client,
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&WhisperUserSettings::default(),
)
.await
.expect("transcribe failed");
assert_eq!(text, expected);
}
#[tokio::test]
async fn whisper_client_returns_status_error_on_500() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/asr"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let client = reqwest::Client::new();
let err = transcribe(
&client,
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&WhisperUserSettings::default(),
)
.await
.expect_err("expected error");
match err {
WhisperError::Status { status, body } => {
assert_eq!(status, 500);
assert_eq!(body, "boom");
}
other => panic!("unexpected error variant: {other:?}"),
}
}
#[tokio::test]
async fn whisper_client_times_out() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/asr"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
.mount(&server)
.await;
let client = reqwest::Client::new();
let err = transcribe(
&client,
&server.uri(),
&fixture("sample.m4a"),
Duration::from_millis(100),
&WhisperUserSettings::default(),
)
.await
.expect_err("expected timeout");
assert!(matches!(err, WhisperError::Http(_)), "expected Http error, got {err:?}");
}
#[tokio::test]
async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
let server = MockServer::start().await;
// Accept any POST; inspect captured body below. language query param is
// still matched strictly to verify override from settings.
Mock::given(method("POST"))
.and(path("/asr"))
.and(query_param("language", "en"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let settings = WhisperUserSettings {
language: Some("en".into()),
hotwords: Some("HOCM Valsalva".into()),
initial_prompt: Some("Kardiologie".into()),
};
transcribe(
&client,
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&settings,
)
.await
.expect("transcribe failed");
let received = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&received[0].body);
assert!(body.contains("name=\"hotwords\""), "hotwords part missing");
assert!(body.contains("HOCM Valsalva"), "hotwords value missing: {body}");
assert!(body.contains("name=\"initial_prompt\""), "initial_prompt part missing");
assert!(body.contains("Kardiologie"), "initial_prompt value missing");
}
#[tokio::test]
async fn whisper_client_omits_empty_optional_fields() {
let server = MockServer::start().await;
// Default settings = all None → only `audio_file` part, language defaults
// to `de`. Assert that neither optional field appears in the body.
Mock::given(method("POST"))
.and(path("/asr"))
.and(query_param("language", "de"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
transcribe(
&client,
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&WhisperUserSettings::default(),
)
.await
.expect("transcribe failed");
// Inspect the captured request to confirm absence of the optional parts.
let received = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&received[0].body);
assert!(!body.contains("name=\"hotwords\""), "hotwords part leaked: {body}");
assert!(!body.contains("name=\"initial_prompt\""), "initial_prompt part leaked: {body}");
}
#[tokio::test]
async fn recovery_enqueues_only_pending_recordings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// User 1, case with two .m4a — one pending, one already transcribed.
let case_a = root.join("dr_a/aaaa");
std::fs::create_dir_all(&case_a).unwrap();
std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
std::fs::write(case_a.join("2026-04-11T10-00-00Z.transcript.txt"), b"done").unwrap();
// User 2, case with one pending .m4a.
let case_b = root.join("dr_b/bbbb");
std::fs::create_dir_all(&case_b).unwrap();
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
let (tx, mut rx) = transcribe::channel();
scan_and_enqueue(root, &tx).await;
drop(tx); // close channel so the loop below terminates
let mut received = Vec::new();
while let Some(job) = rx.recv().await {
received.push(job.audio_path);
}
assert_eq!(received.len(), 2, "got: {received:?}");
// Oldest first:
assert!(received[0].ends_with("2026-04-10T10-00-00Z.m4a"));
assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a"));
}
// -------------------- Worker: failure marker --------------------
fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
Arc::new(Config {
users: vec![User {
slug: "dr_test".into(),
api_key: "k".into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
}],
whisper_url,
whisper_timeout_seconds: 5,
// Ollama URL is irrelevant — worker never reaches it in the failure path.
ollama_url: "http://127.0.0.1:1".into(),
ollama_model: "test".into(),
..Config::test_default()
})
}
#[tokio::test]
async fn worker_renames_audio_to_failed_on_whisper_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/asr"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
// Real case dir with a real m4a fixture so ffmpeg remux succeeds.
let tmp = tempfile::tempdir().unwrap();
let case_dir = tmp.path().join("dr_test/aaaa");
std::fs::create_dir_all(&case_dir).unwrap();
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
let config = test_config_with_whisper(server.uri());
let (tx, rx) = transcribe::channel();
tx.send(transcribe::TranscribeJob {
audio_path: audio.clone(),
user_slug: "dr_test".into(),
})
.await
.unwrap();
drop(tx); // close channel so the worker loop exits after processing the job
let client = reqwest::Client::new();
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
transcribe::worker::run(rx, config, client, busy, vocab).await;
// Audio must have been renamed so recovery skips it next time.
assert!(!audio.exists(), "original .m4a still present");
let failed = case_dir.join("2026-04-13T10-30-00Z.m4a.failed");
assert!(failed.exists(), "expected {} to exist", failed.display());
}
/// The worker must route the Whisper response through the Gazetteer
/// before persisting. Mock returns the drift form "Zerebrum"; the
/// persisted `.transcript.txt` must contain the canonical "Cerebrum".
#[tokio::test]
async fn transcribe_worker_normalizes_whisper_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/asr"))
.respond_with(
ResponseTemplate::new(200).set_body_string("Patient mit Blutung im Zerebrum."),
)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let case_dir = tmp.path().join("dr_test/ffff");
std::fs::create_dir_all(&case_dir).unwrap();
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
let vocab_dir = tempfile::tempdir().unwrap();
std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap();
let vocab = std::sync::Arc::new(
doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
.await
.unwrap(),
);
assert_eq!(vocab.len(), 1);
let config = test_config_with_whisper(server.uri());
let (tx, rx) = transcribe::channel();
tx.send(transcribe::TranscribeJob {
audio_path: audio.clone(),
user_slug: "dr_test".into(),
})
.await
.unwrap();
drop(tx);
let client = reqwest::Client::new();
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
transcribe::worker::run(rx, config, client, busy, vocab).await;
let transcript_path = case_dir.join("2026-04-13T10-30-00Z.transcript.txt");
assert!(transcript_path.exists(), "transcript missing");
let transcript = std::fs::read_to_string(&transcript_path).unwrap();
assert_eq!(transcript, "Patient mit Blutung im Cerebrum.");
}
// -------------------- Ollama client --------------------
const MODEL: &str = "gemma3:4b";
fn ok_chat_body(content: &str) -> serde_json::Value {
json!({
"model": MODEL,
"message": {"role": "assistant", "content": content},
"done": true
})
}
#[tokio::test]
async fn ollama_oneliner_parses_chat_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5))
.await
.expect("call failed");
assert_eq!(out, "HOCM mit Septum-Hypertrophie");
}
#[tokio::test]
async fn ollama_oneliner_normalizes_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges")))
.mount(&server)
.await;
let client = reqwest::Client::new();
let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.unwrap();
assert_eq!(out, "Kniegelenk re., V.a. Meniskus");
}
#[tokio::test]
async fn ollama_oneliner_returns_status_on_500() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let client = reqwest::Client::new();
let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.expect_err("expected error");
match err {
OllamaError::Status { status, body } => {
assert_eq!(status, 500);
assert_eq!(body, "boom");
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn ollama_oneliner_sends_keep_alive_and_model() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(body_partial_json(json!({
"model": MODEL,
"stream": false,
"keep_alive": 0
})))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("OK")))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let _ = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5))
.await
.expect("call failed");
// Mock::expect(1) + server drop verifies the matcher hit exactly once.
}
#[tokio::test]
async fn ollama_oneliner_parse_error_on_missing_content() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"done": true})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.expect_err("expected parse error");
assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}");
}