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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -127,6 +127,7 @@ Risperidon
|
||||
Rivaroxaban
|
||||
Rosuvastatin
|
||||
Salbutamol
|
||||
Sellink
|
||||
Sertralin
|
||||
Sildenafil
|
||||
Simvastatin
|
||||
|
||||
Reference in New Issue
Block a user