Files
doctate/server/src/transcribe/whisper.rs
T
Brummel bb584b6ea0 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.
2026-04-30 16:56:12 +02:00

157 lines
5.1 KiB
Rust

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 WhisperError {
Io(std::io::Error),
Http(reqwest::Error),
Status { status: u16, body: String },
}
impl std::fmt::Display for WhisperError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "whisper io error: {e}"),
Self::Http(e) => write!(f, "whisper http error: {e}"),
Self::Status { status, body } => {
write!(f, "whisper returned {status}: {body}")
}
}
}
}
impl std::error::Error for WhisperError {}
impl WhisperError {
/// Classify this error as transient (retry will likely succeed later) or
/// permanent (retrying the same input will hit the same wall).
///
/// Rationale: the transcribe worker uses this to decide whether to leave
/// the `.m4a` in place (page-load heal re-enqueues it) or rename it to
/// `.m4a.failed` (terminal; only a manual reset un-fails it).
///
/// - `Http`: reqwest wraps all transport issues (DNS, connect, TLS, read,
/// timeout, mid-flight reset) — every one of these is a candidate for
/// „Minerva is back in a minute", so treat as transient.
/// - `Status`: 5xx is server-side trouble; 408 (Request Timeout) and 429
/// (Too Many Requests) are explicit retry signals. Everything else in
/// the 4xx range (400 Bad Request, 415 Unsupported Media, ...) means
/// the payload itself is the problem and replays will fail identically.
/// - `Io`: local filesystem error while reading the audio file. Either
/// the file is gone or the disk is misbehaving; a retry of the same
/// path is not the right response.
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 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 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,
language: Option<&str>,
) -> Result<String, WhisperError> {
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(WhisperError::Io)?;
let part = Part::bytes(bytes)
.file_name(filename)
.mime_str("audio/mp4")
.map_err(WhisperError::Http)?;
let form = Form::new().part("audio_file", part);
let language = language.filter(|s| !s.is_empty()).unwrap_or("de");
let url = format!(
"{}?output=txt&language={language}",
join_url(whisper_url, "/asr")
);
debug!(%url, "posting to whisper");
let response = client
.post(&url)
.timeout(timeout)
.multipart(form)
.send()
.await
.map_err(WhisperError::Http)?;
let status = response.status();
let body = response.text().await.map_err(WhisperError::Http)?;
if !status.is_success() {
return Err(WhisperError::Status {
status: status.as_u16(),
body,
});
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
/// Table-driven classification test. `Http` variant is not publicly
/// constructible (reqwest owns its error ctor), so the transient-Http
/// path is covered by the integration test in
/// `tests/transient_failure_retries_test.rs`. Same code path is exercised
/// here via a 503 Status, which also routes to `is_transient == true`.
#[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 = WhisperError::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 = WhisperError::Io(std::io::Error::other("disk gone"));
assert!(!err.is_transient());
}
}