Refactor: Remove unused Whisper variants

This commit removes the `whisper_variant` and `whisper_hotwords_variant`
fields from the `run_id` generation and the `print_meta_summary`
function.

These variants are no longer used as the project is shifting focus to
LLM-based generation. The `run_full_case.rs` example has also been
updated to reflect this change.
This commit is contained in:
2026-04-30 16:56:12 +02:00
parent 6b7a1cea49
commit bb584b6ea0
18 changed files with 639 additions and 215 deletions
+7
View File
@@ -17,6 +17,13 @@ SESSION_TIMEOUT_HOURS=8
# Set to false for plain-HTTP local dev; browsers refuse Secure cookies on http://
COOKIE_SECURE=true
# ASR backend: 'whisper' (default, whisper-asr-webservice) or 'canary'
# (NeMo Canary container, see docs/canary.md). A switch requires a
# server restart and the OTHER backend's container must be stopped on
# the GPU host — Whisper + Canary together exceed the 12 GB VRAM budget
# of the RTX 3060 (see docs/canary.md §2.6).
ASR_BACKEND=whisper
# Optional filesystem resources (uncomment if used)
# VOCAB_DIR=./vocab
# HUNSPELL_DICT=/usr/share/hunspell/de_DE.dic
+7
View File
@@ -8,9 +8,16 @@ transcript_days = 30
document_days = 0
[whisper]
# Active when ASR_BACKEND=whisper (default).
url = "http://localhost:9000"
timeout_seconds = 120
[canary]
# Active when ASR_BACKEND=canary. Both blocks are always loaded; the
# server reads only the one matching the env-selected backend.
url = "http://minerva.lan:9002"
timeout_seconds = 180
[ollama]
url = "http://localhost:11434"
model = "gemma3:4b"
+78 -24
View File
@@ -3,15 +3,6 @@ use std::path::PathBuf;
use serde::Deserialize;
/// Per-user Whisper transcription settings from users.toml.
/// All fields optional — missing `[user.whisper]` block yields defaults (None).
#[derive(Debug, Default, Clone, Deserialize)]
pub struct WhisperUserSettings {
pub language: Option<String>,
pub hotwords: Option<String>,
pub initial_prompt: Option<String>,
}
/// Per-user retention settings from users.toml.
///
/// `auto_close_days = 0` disables the auto-close sweep (a case is never
@@ -48,8 +39,11 @@ pub struct User {
pub api_key: String,
pub web_password: String,
pub role: String,
/// ASR input language code (e.g. "de", "en"). Forwarded as the
/// `language` form/query field to the active ASR backend
/// (Whisper or Canary). Missing field → backend default ("de").
#[serde(default)]
pub whisper: WhisperUserSettings,
pub language: Option<String>,
#[serde(default)]
pub retention: RetentionUserSettings,
/// Time window (hours) that defines which cases are visible to this
@@ -75,6 +69,31 @@ struct UsersFile {
user: Vec<User>,
}
/// Active ASR backend, picked once at server startup from the
/// `ASR_BACKEND` env var. `Whisper` (default) routes the worker to the
/// `whisper-asr-webservice` at `[whisper].url`; `Canary` routes it to
/// the NeMo Canary container at `[canary].url`. Switching backends
/// requires a server restart — both cannot run on the same GPU at
/// once (12 GB VRAM budget; see docs/canary.md §2.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AsrBackend {
#[default]
Whisper,
Canary,
}
impl std::str::FromStr for AsrBackend {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"whisper" => Ok(Self::Whisper),
"canary" => Ok(Self::Canary),
other => Err(format!("expected 'whisper' or 'canary', got '{other}'")),
}
}
}
/// Bootstrap config loaded once from `.env` at startup. Runtime-tunable
/// values (retention, whisper/ollama/llm endpoints, prompts) live in
/// `Settings` (loaded from `settings.toml`).
@@ -104,6 +123,10 @@ pub struct Config {
/// Set `COOKIE_SECURE=false` only for plain-HTTP local development —
/// browsers refuse `Secure` cookies on non-HTTPS origins.
pub cookie_secure: bool,
/// Selected ASR backend (Whisper or Canary). Read from `ASR_BACKEND`
/// env var at startup; default `Whisper`. Worker uses this to dispatch
/// the transcription call to the matching endpoint.
pub asr_backend: AsrBackend,
}
impl Config {
@@ -127,6 +150,9 @@ impl Config {
)),
session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8),
cookie_secure: optional_env_parsed("COOKIE_SECURE", true),
asr_backend: optional_env("ASR_BACKEND", "whisper")
.parse::<AsrBackend>()
.unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")),
}
}
@@ -157,6 +183,7 @@ impl Config {
hunspell_dict_path: PathBuf::new(),
session_timeout_hours: 8,
cookie_secure: false,
asr_backend: AsrBackend::Whisper,
}
}
}
@@ -243,7 +270,7 @@ mod tests {
api_key: api_key.into(),
web_password: String::new(),
role: "doctor".into(),
whisper: WhisperUserSettings::default(),
language: None,
retention: RetentionUserSettings::default(),
window_hours: default_window_hours(),
preview_lines: default_preview_lines(),
@@ -251,7 +278,7 @@ mod tests {
}
#[test]
fn parses_user_without_whisper_block() {
fn parses_user_without_language() {
let toml_str = r#"
[[user]]
slug = "a"
@@ -261,9 +288,7 @@ mod tests {
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let u = &parsed.user[0];
assert!(u.whisper.language.is_none());
assert!(u.whisper.hotwords.is_none());
assert!(u.whisper.initial_prompt.is_none());
assert!(u.language.is_none());
}
#[test]
@@ -348,23 +373,17 @@ mod tests {
}
#[test]
fn parses_user_with_full_whisper_block() {
fn parses_user_with_explicit_language() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
[user.whisper]
language = "de"
hotwords = "HOCM Valsalva"
initial_prompt = "Kardiologie"
language = "de"
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let w = &parsed.user[0].whisper;
assert_eq!(w.language.as_deref(), Some("de"));
assert_eq!(w.hotwords.as_deref(), Some("HOCM Valsalva"));
assert_eq!(w.initial_prompt.as_deref(), Some("Kardiologie"));
assert_eq!(parsed.user[0].language.as_deref(), Some("de"));
}
#[test]
@@ -430,4 +449,39 @@ mod tests {
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.user[0].window_hours, 168);
}
// ----- AsrBackend -----
#[test]
fn asr_backend_default_is_whisper() {
assert_eq!(AsrBackend::default(), AsrBackend::Whisper);
}
#[test]
fn asr_backend_parses_whisper() {
assert_eq!(
"whisper".parse::<AsrBackend>().unwrap(),
AsrBackend::Whisper
);
}
#[test]
fn asr_backend_parses_canary() {
assert_eq!("canary".parse::<AsrBackend>().unwrap(), AsrBackend::Canary);
}
#[test]
fn asr_backend_parses_case_insensitive_with_whitespace() {
assert_eq!(
" Whisper ".parse::<AsrBackend>().unwrap(),
AsrBackend::Whisper
);
assert_eq!("CANARY".parse::<AsrBackend>().unwrap(), AsrBackend::Canary);
}
#[test]
fn asr_backend_rejects_invalid() {
let err = "wav2vec".parse::<AsrBackend>().unwrap_err();
assert!(err.contains("wav2vec"), "got: {err}");
}
}
+10 -1
View File
@@ -10,7 +10,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use doctate_server::AppState;
use doctate_server::analyze;
use doctate_server::config::Config;
use doctate_server::config::{AsrBackend, Config};
use doctate_server::events;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::settings::Settings;
@@ -54,6 +54,15 @@ async fn main() {
);
}
info!(backend = ?config.asr_backend, "ASR backend");
if config.asr_backend == AsrBackend::Canary {
warn!(
"ASR_BACKEND=canary — Whisper container on the GPU host \
must be stopped (12 GB VRAM budget shared with Ollama; \
see docs/canary.md §2.6)."
);
}
info!(
prompt_chars = settings.llm.system_prompt.chars().count(),
"system prompt loaded"
+40
View File
@@ -13,6 +13,8 @@ pub struct Settings {
#[serde(default)]
pub whisper: WhisperSettings,
#[serde(default)]
pub canary: CanarySettings,
#[serde(default)]
pub ollama: OllamaSettings,
#[serde(default)]
pub llm: LlmSettings,
@@ -72,6 +74,36 @@ fn default_whisper_timeout_seconds() -> u64 {
120
}
/// Endpoint for the optional NeMo Canary container service. Active only
/// when `ASR_BACKEND=canary` is set in `.env`. Both this block and
/// `[whisper]` are always loaded; the worker reads only the active one.
#[derive(Debug, Clone, Deserialize)]
pub struct CanarySettings {
#[serde(default = "default_canary_url")]
pub url: String,
#[serde(default = "default_canary_timeout_seconds")]
pub timeout_seconds: u64,
}
impl Default for CanarySettings {
fn default() -> Self {
Self {
url: default_canary_url(),
timeout_seconds: default_canary_timeout_seconds(),
}
}
}
fn default_canary_url() -> String {
"http://localhost:9002".into()
}
/// Higher than Whisper's 120s default: Canary's buffered pipeline for
/// audio >25s adds chunk-stitching cost (see docs/canary.md §4.1), so
/// long dictations need a wider safety margin.
fn default_canary_timeout_seconds() -> u64 {
180
}
#[derive(Debug, Clone, Deserialize)]
pub struct OllamaSettings {
#[serde(default = "default_ollama_url")]
@@ -180,6 +212,10 @@ mod tests {
url = "http://h:1"
timeout_seconds = 60
[canary]
url = "http://h:9"
timeout_seconds = 240
[ollama]
url = "http://h:2"
model = "x"
@@ -197,6 +233,8 @@ system_prompt = "p"
.unwrap();
assert_eq!(s.whisper.url, "http://h:1");
assert_eq!(s.whisper.timeout_seconds, 60);
assert_eq!(s.canary.url, "http://h:9");
assert_eq!(s.canary.timeout_seconds, 240);
assert_eq!(s.ollama.keep_alive, 5);
assert_eq!(s.llm.api_key, "k");
assert_eq!(s.llm.system_prompt, "p");
@@ -207,6 +245,8 @@ system_prompt = "p"
let s: Settings = toml::from_str("").unwrap();
assert_eq!(s.whisper.url, "http://localhost:10300");
assert_eq!(s.whisper.timeout_seconds, 120);
assert_eq!(s.canary.url, "http://localhost:9002");
assert_eq!(s.canary.timeout_seconds, 180);
assert_eq!(s.ollama.url, "http://localhost:11434");
assert_eq!(s.ollama.model, "gemma3:4b");
assert_eq!(s.llm.timeout_seconds, 180);
+143
View File
@@ -0,0 +1,143 @@
use std::path::Path;
use std::time::Duration;
use doctate_common::join_url;
use reqwest::multipart::{Form, Part};
use tracing::debug;
#[derive(Debug)]
pub enum CanaryError {
Io(std::io::Error),
Http(reqwest::Error),
Status { status: u16, body: String },
}
impl std::fmt::Display for CanaryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "canary io error: {e}"),
Self::Http(e) => write!(f, "canary http error: {e}"),
Self::Status { status, body } => {
write!(f, "canary returned {status}: {body}")
}
}
}
}
impl std::error::Error for CanaryError {}
impl CanaryError {
/// Mirrors `WhisperError::is_transient`: `Http` (network), 5xx/408/429
/// → transient (page-load heal re-enqueues); `Io` and other 4xx →
/// permanent (rename to `.m4a.failed`).
pub fn is_transient(&self) -> bool {
match self {
Self::Http(_) => true,
Self::Status { status, .. } => {
*status == 408 || *status == 429 || (500..=599).contains(status)
}
Self::Io(_) => false,
}
}
}
/// POST the audio file to the Canary `/inference` endpoint and return the
/// transcript text.
///
/// Form fields per docs/canary.md §2.4:
/// - `file`: the multipart upload (any container — Canary's service
/// ffmpegs to 16 kHz mono internally).
/// - `language`: defaults to `de` when the caller passes `None` or empty.
/// - `pnc=yes`: Punctuation and Capitalization, matching the Whisper path.
/// - `timestamps=no`: pipeline does not consume word/segment timestamps.
/// - `response_format=text`: plain-text body, structurally identical to
/// Whisper's `output=txt` response — no downstream parsing needed.
pub async fn transcribe(
client: &reqwest::Client,
canary_url: &str,
audio: &Path,
timeout: Duration,
language: Option<&str>,
) -> Result<String, CanaryError> {
let filename = audio
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "audio.m4a".to_string());
let bytes = tokio::fs::read(audio).await.map_err(CanaryError::Io)?;
let part = Part::bytes(bytes)
.file_name(filename)
.mime_str("audio/mp4")
.map_err(CanaryError::Http)?;
let lang = language.filter(|s| !s.is_empty()).unwrap_or("de");
let form = Form::new()
.part("file", part)
.text("language", lang.to_owned())
.text("pnc", "yes")
.text("timestamps", "no")
.text("response_format", "text");
let url = join_url(canary_url, "/inference");
debug!(%url, language = lang, "posting to canary");
let response = client
.post(&url)
.timeout(timeout)
.multipart(form)
.send()
.await
.map_err(CanaryError::Http)?;
let status = response.status();
let body = response.text().await.map_err(CanaryError::Http)?;
if !status.is_success() {
return Err(CanaryError::Status {
status: status.as_u16(),
body,
});
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_transient_classifies_status_codes() {
let cases: &[(u16, bool)] = &[
(400, false),
(401, false),
(403, false),
(404, false),
(408, true),
(415, false),
(422, false),
(429, true),
(500, true),
(502, true),
(503, true),
(504, true),
];
for &(status, expected) in cases {
let err = CanaryError::Status {
status,
body: String::new(),
};
assert_eq!(
err.is_transient(),
expected,
"status {status} expected is_transient={expected}"
);
}
}
#[test]
fn is_transient_io_is_permanent() {
let err = CanaryError::Io(std::io::Error::other("disk gone"));
assert!(!err.is_transient());
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use tokio::sync::mpsc;
pub mod canary;
pub mod ffmpeg;
pub mod ollama;
pub mod recovery;
@@ -14,7 +15,7 @@ pub const QUEUE_DEPTH: usize = 64;
pub struct TranscribeJob {
pub audio_path: PathBuf,
/// Slug of the user who owns this recording. Used by the worker to look up
/// per-user transcription settings (hotwords, language, initial_prompt).
/// the per-user transcription language.
pub user_slug: String,
}
+8 -25
View File
@@ -5,8 +5,6 @@ use doctate_common::join_url;
use reqwest::multipart::{Form, Part};
use tracing::debug;
use crate::config::WhisperUserSettings;
#[derive(Debug)]
pub enum WhisperError {
Io(std::io::Error),
@@ -59,21 +57,17 @@ impl WhisperError {
/// POST the audio file to whisper-asr-webservice and return the transcript text.
///
/// `output=txt` → plain-text response. `language` pins the language so the model
/// skips detection (defaults to `de` when the user has no override). 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.
///
/// Per-user `hotwords` and `initial_prompt` are forwarded as multipart form
/// fields when non-empty; our `whisper/`-service passes them straight into
/// `faster_whisper.WhisperModel.transcribe()`.
/// `output=txt` → plain-text response. `language` pins the language so the
/// model skips detection (defaults to `de` when the caller passes `None` or
/// an empty string). 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,
audio: &Path,
timeout: Duration,
settings: &WhisperUserSettings,
language: Option<&str>,
) -> Result<String, WhisperError> {
let filename = audio
.file_name()
@@ -86,20 +80,9 @@ pub async fn transcribe(
.file_name(filename)
.mime_str("audio/mp4")
.map_err(WhisperError::Http)?;
let mut form = Form::new().part("audio_file", part);
let form = Form::new().part("audio_file", part);
if let Some(hotwords) = settings.hotwords.as_deref().filter(|s| !s.is_empty()) {
form = form.part("hotwords", Part::text(hotwords.to_owned()));
}
if let Some(prompt) = settings.initial_prompt.as_deref().filter(|s| !s.is_empty()) {
form = form.part("initial_prompt", Part::text(prompt.to_owned()));
}
let language = settings
.language
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("de");
let language = language.filter(|s| !s.is_empty()).unwrap_or("de");
let url = format!(
"{}?output=txt&language={language}",
join_url(whisper_url, "/asr")
+86 -29
View File
@@ -8,8 +8,8 @@ use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::{error, info, warn};
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
use crate::config::{Config, WhisperUserSettings};
use super::{TranscribeReceiver, canary, ffmpeg, ollama, whisper};
use crate::config::{AsrBackend, Config};
use crate::events::{self, CaseEventKind, EventSender};
use crate::gazetteer::Gazetteer;
use crate::loudness;
@@ -20,8 +20,36 @@ 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.
/// Backend-agnostic ASR error wrapper. Lets the worker handle Whisper
/// and Canary failures with identical match logic — both expose
/// `is_transient()` and `Display`, which is all the surrounding code
/// needs. Cheaper than a trait object: statically dispatched, no vtable.
enum AsrError {
Whisper(whisper::WhisperError),
Canary(canary::CanaryError),
}
impl AsrError {
fn is_transient(&self) -> bool {
match self {
Self::Whisper(e) => e.is_transient(),
Self::Canary(e) => e.is_transient(),
}
}
}
impl std::fmt::Display for AsrError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Whisper(e) => e.fmt(f),
Self::Canary(e) => e.fmt(f),
}
}
}
/// Consume transcription jobs sequentially. The active ASR backend
/// (Whisper or Canary) 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,
@@ -34,7 +62,23 @@ pub async fn run(
oneliner_locks: OnelinerLocks,
) {
info!("Transcription worker started");
let timeout = Duration::from_secs(settings.whisper.timeout_seconds);
// Resolve the active backend once at worker start. URL and timeout
// are frozen for the worker's lifetime — switching backends requires
// a server restart (tied to the GPU-VRAM exclusivity of the two
// models, see docs/canary.md §2.6).
let backend = config.asr_backend;
let (asr_url, timeout) = match backend {
AsrBackend::Whisper => (
settings.whisper.url.clone(),
Duration::from_secs(settings.whisper.timeout_seconds),
),
AsrBackend::Canary => (
settings.canary.url.clone(),
Duration::from_secs(settings.canary.timeout_seconds),
),
};
info!(?backend, url = %asr_url, "ASR backend selected");
while let Some(job) = rx.recv().await {
let _guard = BusyGuard::new(worker_busy.clone());
@@ -49,15 +93,14 @@ pub async fn run(
continue;
}
// Look up per-user Whisper settings live from the shared config so that
// Look up per-user language 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 user_whisper: WhisperUserSettings = config
// Unknown slug or missing field → None (service falls back to "de").
let user_language: Option<String> = config
.users
.iter()
.find(|u| u.slug == job.user_slug)
.map(|u| u.whisper.clone())
.unwrap_or_default();
.and_then(|u| u.language.clone());
info!(audio = %audio_path.display(), user = %job.user_slug, "Transcribing");
@@ -70,12 +113,12 @@ pub async fn run(
}
};
// Run audio analysis (duration + volumedetect) and Whisper
// concurrently against the remuxed tempfile. The volumedetect
// pass is decode-bound on a CPU core (~100-500 ms for typical
// dictation lengths); Whisper dominates the wallclock with a
// GPU/network round-trip in seconds, so the analysis cost
// disappears behind the join.
// Run audio analysis (duration + volumedetect) and the active
// ASR backend concurrently against the remuxed tempfile. The
// volumedetect pass is decode-bound on a CPU core (~100-500 ms
// for typical dictation lengths); the ASR call dominates the
// wallclock with a GPU/network round-trip in seconds, so the
// analysis cost disappears behind the join.
//
// `tokio::join!` (not `try_join!`) intentionally awaits both
// futures even on error: a failed analysis must not throw
@@ -83,23 +126,37 @@ pub async fn run(
// drop any analysis values — without a meta sidecar the
// page-load heal re-enqueues, and that retry must run the
// analysis fresh.
let (analysis_result, whisper_result) = tokio::join!(
ffmpeg::analyze_audio(remuxed.path()),
whisper::transcribe(
&client,
&settings.whisper.url,
remuxed.path(),
timeout,
&user_whisper,
),
);
let asr_future = async {
match backend {
AsrBackend::Whisper => whisper::transcribe(
&client,
&asr_url,
remuxed.path(),
timeout,
user_language.as_deref(),
)
.await
.map_err(AsrError::Whisper),
AsrBackend::Canary => canary::transcribe(
&client,
&asr_url,
remuxed.path(),
timeout,
user_language.as_deref(),
)
.await
.map_err(AsrError::Canary),
}
};
let (analysis_result, asr_result) =
tokio::join!(ffmpeg::analyze_audio(remuxed.path()), asr_future,);
// Whisper is the gating outcome: without a transcript we don't
// ASR is the gating outcome: without a transcript we don't
// write any meta, regardless of whether the analysis succeeded.
let text = match whisper_result {
let text = match asr_result {
Ok(t) => t,
Err(e) => {
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
error!(audio = %audio_path.display(), error = %e, "ASR call failed");
if e.is_transient() {
// Leave the recording as plain `.m4a` (no `.failed` suffix)
// so the page-load heal (`enqueue_pending_for_user`) picks
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn test_user(slug: &str) -> User {
api_key: format!("key-{slug}"),
web_password: hash_password(TEST_PASSWORD),
role: "doctor".into(),
whisper: Default::default(),
language: None,
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
+5 -64
View File
@@ -4,7 +4,6 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use doctate_server::config::WhisperUserSettings;
use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
@@ -79,7 +78,7 @@ async fn whisper_client_posts_multipart_and_returns_text() {
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&WhisperUserSettings::default(),
None,
)
.await
.expect("transcribe failed");
@@ -103,7 +102,7 @@ async fn whisper_client_returns_status_error_on_500() {
&server.uri(),
&fixture("sample.m4a"),
Duration::from_secs(10),
&WhisperUserSettings::default(),
None,
)
.await
.expect_err("expected error");
@@ -133,7 +132,7 @@ async fn whisper_client_times_out() {
&server.uri(),
&fixture("sample.m4a"),
Duration::from_millis(100),
&WhisperUserSettings::default(),
None,
)
.await
.expect_err("expected timeout");
@@ -145,11 +144,9 @@ async fn whisper_client_times_out() {
}
#[tokio::test]
async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
async fn whisper_client_forwards_explicit_language() {
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"))
@@ -159,71 +156,15 @@ async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
.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,
Some("en"),
)
.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]
+1
View File
@@ -127,6 +127,7 @@ Risperidon
Rivaroxaban
Rosuvastatin
Salbutamol
Sellink
Sertralin
Sildenafil
Simvastatin