bb584b6ea0
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.
157 lines
5.1 KiB
Rust
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());
|
|
}
|
|
}
|