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
+1 -6
View File
@@ -54,18 +54,13 @@ fn print_meta_summary(run: &Path) -> Result<()> {
} }
}; };
let v: serde_json::Value = serde_json::from_str(&raw).context("parsing meta.json")?; let v: serde_json::Value = serde_json::from_str(&raw).context("parsing meta.json")?;
let w = v.get("whisper_variant").and_then(|x| x.as_str()).unwrap_or("?");
let h = v
.get("whisper_hotwords_variant")
.and_then(|x| x.as_str())
.unwrap_or("none");
let l = v.get("llm_variant").and_then(|x| x.as_str()).unwrap_or("?"); let l = v.get("llm_variant").and_then(|x| x.as_str()).unwrap_or("?");
let model = v.get("llm_model").and_then(|x| x.as_str()).unwrap_or("?"); let model = v.get("llm_model").and_then(|x| x.as_str()).unwrap_or("?");
let wallclock = v let wallclock = v
.pointer("/timings_ms/wallclock_ms") .pointer("/timings_ms/wallclock_ms")
.and_then(|x| x.as_u64()) .and_then(|x| x.as_u64())
.unwrap_or(0); .unwrap_or(0);
println!(" whisper={w} hotwords={h} llm={l} model={model} wallclock={wallclock}ms"); println!(" llm={l} model={model} wallclock={wallclock}ms");
Ok(()) Ok(())
} }
+19 -44
View File
@@ -6,14 +6,12 @@
//! Usage: //! Usage:
//! run_full_case \ //! run_full_case \
//! --case-dir case_c414cf52 \ //! --case-dir case_c414cf52 \
//! --whisper-prompt prompts/whisper/v1_dosing_minimal.txt \
//! --whisper-hotwords prompts/whisper_hotwords/v1_drugs.txt \
//! --llm-prompt prompts/llm/baseline.txt \ //! --llm-prompt prompts/llm/baseline.txt \
//! --runs 3 //! --runs 3
//! //!
//! Path conventions: //! Path conventions:
//! - `--case-dir` is relative to the experiments/ working directory //! - `--case-dir` is relative to the experiments/ working directory
//! - `--*-prompt` and `--*-hotwords` are paths relative to `--case-dir` //! - `--llm-prompt` is a path relative to `--case-dir`
//! - `--settings` defaults to `../server/settings.toml` //! - `--settings` defaults to `../server/settings.toml`
//! - `--vocab-dir` defaults to `../server/vocab` //! - `--vocab-dir` defaults to `../server/vocab`
@@ -28,7 +26,6 @@ use doctate_experiments::{
use doctate_server::analyze::llm::{self as srv_llm, LlmSettings as LlmCallSettings}; use doctate_server::analyze::llm::{self as srv_llm, LlmSettings as LlmCallSettings};
use doctate_server::analyze::prompt::render_prompt; use doctate_server::analyze::prompt::render_prompt;
use doctate_server::analyze::{AnalysisInput, RecordingInput}; use doctate_server::analyze::{AnalysisInput, RecordingInput};
use doctate_server::config::WhisperUserSettings;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::settings::Settings; use doctate_server::settings::Settings;
use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ffmpeg::remux_faststart;
@@ -42,17 +39,6 @@ struct Args {
#[arg(long)] #[arg(long)]
case_dir: PathBuf, case_dir: PathBuf,
/// Path to a Whisper `initial_prompt` file. Empty file → no
/// initial_prompt forwarded. Prompts are fall-übergreifend, typically
/// under `prompts/whisper/`.
#[arg(long)]
whisper_prompt: PathBuf,
/// Optional Whisper `hotwords` file (handle with care — see
/// `prompts/whisper_hotwords/README.md`).
#[arg(long)]
whisper_hotwords: Option<PathBuf>,
/// Path to the LLM system prompt file. Typically under `prompts/llm/`. /// Path to the LLM system prompt file. Typically under `prompts/llm/`.
#[arg(long)] #[arg(long)]
llm_prompt: PathBuf, llm_prompt: PathBuf,
@@ -99,19 +85,8 @@ async fn main() -> Result<()> {
); );
} }
let whisper_prompt_text = load_prompt(&args.whisper_prompt)?;
let whisper_hotwords_text = match args.whisper_hotwords.as_ref() {
Some(p) => Some(load_prompt(p)?),
None => None,
};
let llm_system_prompt = load_prompt(&args.llm_prompt)?; let llm_system_prompt = load_prompt(&args.llm_prompt)?;
let user_whisper = WhisperUserSettings {
language: Some("de".to_string()),
hotwords: whisper_hotwords_text.clone().filter(|s| !s.is_empty()),
initial_prompt: Some(whisper_prompt_text.clone()).filter(|s| !s.is_empty()),
};
let (gazetteer, dict_mode) = load_gazetteer(&args.vocab_dir, &args.hunspell_stem).await?; let (gazetteer, dict_mode) = load_gazetteer(&args.vocab_dir, &args.hunspell_stem).await?;
info!( info!(
vocab_dir = %args.vocab_dir.display(), vocab_dir = %args.vocab_dir.display(),
@@ -129,14 +104,12 @@ async fn main() -> Result<()> {
.build() .build()
.context("building reqwest client")?; .context("building reqwest client")?;
let whisper_variant_id = stem(&args.whisper_prompt);
let hotwords_variant_id = args.whisper_hotwords.as_ref().map(stem);
let llm_variant_id = stem(&args.llm_prompt); let llm_variant_id = stem(&args.llm_prompt);
let runs_root = data_runs_root(&case_dir)?; let runs_root = data_runs_root(&case_dir)?;
for run_index in 1..=args.runs { for run_index in 1..=args.runs {
let id = run_id(&whisper_variant_id, &llm_variant_id, run_index); let id = run_id(&llm_variant_id, run_index);
let run_dir = runs_root.join(&id); let run_dir = runs_root.join(&id);
std::fs::create_dir_all(run_dir.join("transcripts"))?; std::fs::create_dir_all(run_dir.join("transcripts"))?;
@@ -149,12 +122,7 @@ async fn main() -> Result<()> {
for audio in &audios { for audio in &audios {
let recorded_at = stem(audio); let recorded_at = stem(audio);
let t_whisper = Instant::now(); let t_whisper = Instant::now();
let raw = transcribe_one( let raw = transcribe_one(&http, &settings, Some("de"), audio)
&http,
&settings,
&user_whisper,
audio,
)
.await .await
.with_context(|| format!("transcribing {}", audio.display()))?; .with_context(|| format!("transcribing {}", audio.display()))?;
timings.whisper_total_ms += t_whisper.elapsed().as_millis() as u64; timings.whisper_total_ms += t_whisper.elapsed().as_millis() as u64;
@@ -163,7 +131,9 @@ async fn main() -> Result<()> {
let cleaned = gazetteer.replace(&raw); let cleaned = gazetteer.replace(&raw);
timings.gazetteer_total_ms += t_gaz.elapsed().as_millis() as u64; timings.gazetteer_total_ms += t_gaz.elapsed().as_millis() as u64;
let transcript_path = run_dir.join("transcripts").join(format!("{recorded_at}.txt")); let transcript_path = run_dir
.join("transcripts")
.join(format!("{recorded_at}.txt"));
atomic_write(&transcript_path, &cleaned)?; atomic_write(&transcript_path, &cleaned)?;
recordings_meta.push(RecordingMeta { recordings_meta.push(RecordingMeta {
@@ -191,7 +161,8 @@ async fn main() -> Result<()> {
if !args.no_llm { if !args.no_llm {
let t_llm = Instant::now(); let t_llm = Instant::now();
let document_raw = call_llm(&http, &settings, &llm_system_prompt, &user_content).await?; let document_raw =
call_llm(&http, &settings, &llm_system_prompt, &user_content).await?;
timings.llm_ms = t_llm.elapsed().as_millis() as u64; timings.llm_ms = t_llm.elapsed().as_millis() as u64;
// Post-LLM gazetteer pass — mirrors `server/src/analyze/worker.rs:150`. // Post-LLM gazetteer pass — mirrors `server/src/analyze/worker.rs:150`.
// Catches LLM-drift back to non-canonical forms (e.g. anglicization) // Catches LLM-drift back to non-canonical forms (e.g. anglicization)
@@ -212,8 +183,6 @@ async fn main() -> Result<()> {
.file_name() .file_name()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(), .unwrap_or_default(),
whisper_variant: whisper_variant_id.clone(),
whisper_hotwords_variant: hotwords_variant_id.clone(),
llm_variant: llm_variant_id.clone(), llm_variant: llm_variant_id.clone(),
whisper_url: settings.whisper.url.clone(), whisper_url: settings.whisper.url.clone(),
llm_url: settings.llm.url.clone(), llm_url: settings.llm.url.clone(),
@@ -280,9 +249,9 @@ async fn load_gazetteer(
fn dict_mode_clone(m: &GazetteerDictMode) -> GazetteerDictMode { fn dict_mode_clone(m: &GazetteerDictMode) -> GazetteerDictMode {
match m { match m {
GazetteerDictMode::NoDict => GazetteerDictMode::NoDict, GazetteerDictMode::NoDict => GazetteerDictMode::NoDict,
GazetteerDictMode::SpellbookHunspell { stem } => GazetteerDictMode::SpellbookHunspell { GazetteerDictMode::SpellbookHunspell { stem } => {
stem: stem.clone(), GazetteerDictMode::SpellbookHunspell { stem: stem.clone() }
}, }
} }
} }
@@ -325,14 +294,20 @@ fn stem<P: AsRef<Path>>(p: P) -> String {
async fn transcribe_one( async fn transcribe_one(
http: &reqwest::Client, http: &reqwest::Client,
settings: &Settings, settings: &Settings,
user_whisper: &WhisperUserSettings, language: Option<&str>,
audio: &Path, audio: &Path,
) -> Result<String> { ) -> Result<String> {
let remuxed = remux_faststart(audio) let remuxed = remux_faststart(audio)
.await .await
.with_context(|| format!("ffmpeg remux failed for {}", audio.display()))?; .with_context(|| format!("ffmpeg remux failed for {}", audio.display()))?;
let timeout = Duration::from_secs(settings.whisper.timeout_seconds); let timeout = Duration::from_secs(settings.whisper.timeout_seconds);
let text = srv_whisper::transcribe(http, &settings.whisper.url, remuxed.path(), timeout, user_whisper) let text = srv_whisper::transcribe(
http,
&settings.whisper.url,
remuxed.path(),
timeout,
language,
)
.await .await
.map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?; .map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?;
Ok(text) Ok(text)
+1 -3
View File
@@ -119,7 +119,7 @@ async fn main() -> Result<()> {
let runs_root = data_runs_root(&case_dir)?; let runs_root = data_runs_root(&case_dir)?;
for run_index in 1..=args.runs { for run_index in 1..=args.runs {
let id = run_id("none", &llm_variant_id, run_index); let id = run_id(&llm_variant_id, run_index);
let run_dir = runs_root.join(&id); let run_dir = runs_root.join(&id);
std::fs::create_dir_all(&run_dir)?; std::fs::create_dir_all(&run_dir)?;
@@ -147,8 +147,6 @@ async fn main() -> Result<()> {
.file_name() .file_name()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(), .unwrap_or_default(),
whisper_variant: "none".into(),
whisper_hotwords_variant: None,
llm_variant: llm_variant_id.clone(), llm_variant: llm_variant_id.clone(),
whisper_url: String::new(), whisper_url: String::new(),
llm_url: settings.llm.url.clone(), llm_url: settings.llm.url.clone(),
+6 -6
View File
@@ -17,13 +17,15 @@ use time::OffsetDateTime;
use time::format_description::well_known::Iso8601; use time::format_description::well_known::Iso8601;
/// Generate a run identifier in the form /// Generate a run identifier in the form
/// `<UTC-ISO>_w-<wid>_l-<lid>_run<n>`. Used as the directory name under /// `<UTC-ISO>_l-<lid>_run<n>`. Used as the directory name under
/// `case_<id>/runs/`. /// `case_<id>/runs/`.
pub fn run_id(whisper_variant: &str, llm_variant: &str, run_index: u32) -> String { pub fn run_id(llm_variant: &str, run_index: u32) -> String {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let stamp = now.format(&Iso8601::DEFAULT).unwrap_or_else(|_| "unknown".into()); let stamp = now
.format(&Iso8601::DEFAULT)
.unwrap_or_else(|_| "unknown".into());
let safe_stamp = stamp.replace(':', "-").replace('.', "-"); let safe_stamp = stamp.replace(':', "-").replace('.', "-");
format!("{safe_stamp}_w-{whisper_variant}_l-{llm_variant}_run{run_index}") format!("{safe_stamp}_l-{llm_variant}_run{run_index}")
} }
/// Read a prompt file. Returns empty string for an empty file (used as /// Read a prompt file. Returns empty string for an empty file (used as
@@ -61,8 +63,6 @@ pub struct RunMeta {
pub run_id: String, pub run_id: String,
pub created_at_utc: String, pub created_at_utc: String,
pub case: String, pub case: String,
pub whisper_variant: String,
pub whisper_hotwords_variant: Option<String>,
pub llm_variant: String, pub llm_variant: String,
pub whisper_url: String, pub whisper_url: String,
pub llm_url: String, pub llm_url: String,
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Start doctate-server with the chosen ASR backend (whisper or canary).
#
# Coordinates the GPU host (minerva.lan) so the chosen ASR container is
# the only heavy GPU resident:
# - Whisper (~1.5-3 GB) coexists with Ollama (~3-4 GB) on the RTX 3060.
# - Canary (~9.7 GB) does NOT — Ollama must be unloaded first, else
# Canary OOMs on its last few MB of model weights.
# See docs/canary.md §2.6 for the VRAM accounting.
#
# Flow:
# 1. cargo build -p doctate-server (fail fast, BEFORE touching minerva)
# 2. ssh minerva.lan: docker compose stop the OTHER ASR stack
# 3. (canary only) unload all Ollama models — must happen BEFORE the
# canary container starts, otherwise canary loads its model into a
# still-occupied GPU and OOMs in the last few MB. Order matters:
# „start canary, then free VRAM" is too late — canary's model load
# runs in the container background as soon as `compose up -d` returns.
# 4. ssh minerva.lan: docker compose up -d the chosen stack — for
# canary, the GPU is now guaranteed empty.
# 5. poll the chosen backend until it answers, with progress pulses
# and periodic container-log snapshots so a stuck start is visible
# 6. exec cargo run -p doctate-server with ASR_BACKEND= exported
#
# Usage:
# scripts/run-server.sh whisper
# scripts/run-server.sh canary
#
# Env overrides (all optional):
# MINERVA_HOST default minerva.lan
# WHISPER_STACK default /opt/stacks/doctate-whisper
# CANARY_STACK default /opt/stacks/doctate-canary
# WHISPER_HOST_PORT default 9001
# CANARY_HOST_PORT default 9002
# OLLAMA_HOST_PORT default 11434
# HEALTH_TIMEOUT_SECS default 600 (cold model-download room)
# HEALTH_POLL_SECS default 3 (interval between probes)
# LOG_SNAPSHOT_SECS default 30 (interval between log dumps)
set -euo pipefail
MINERVA_HOST="${MINERVA_HOST:-minerva.lan}"
WHISPER_STACK="${WHISPER_STACK:-/opt/stacks/doctate-whisper}"
CANARY_STACK="${CANARY_STACK:-/opt/stacks/doctate-canary}"
WHISPER_HOST_PORT="${WHISPER_HOST_PORT:-9001}"
CANARY_HOST_PORT="${CANARY_HOST_PORT:-9002}"
OLLAMA_HOST_PORT="${OLLAMA_HOST_PORT:-11434}"
HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-600}"
HEALTH_POLL_SECS="${HEALTH_POLL_SECS:-3}"
LOG_SNAPSHOT_SECS="${LOG_SNAPSHOT_SECS:-30}"
usage() {
cat <<EOF >&2
Usage: $0 <whisper|canary>
Coordinates the GPU host (\$MINERVA_HOST) so only the matching ASR
container runs (and, for canary, Ollama is unloaded), then starts
doctate-server locally with the matching ASR_BACKEND env var.
See header comment for env overrides.
EOF
exit 64
}
[[ $# -eq 1 ]] || usage
case "$1" in
whisper|canary) BACKEND="$1" ;;
*) usage ;;
esac
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT/server"
# ---- Step 1: build before touching minerva. -------------------------------
echo ">>> [1/6] cargo build -p doctate-server"
cargo build -p doctate-server
# ---- Step 2: pick stop/start targets. -------------------------------------
if [[ "$BACKEND" == "whisper" ]]; then
STOP_STACK="$CANARY_STACK"
START_STACK="$WHISPER_STACK"
HEALTH_KIND="tcp"
HEALTH_PORT="$WHISPER_HOST_PORT"
HEALTH_DESC="TCP $MINERVA_HOST:$WHISPER_HOST_PORT"
else
STOP_STACK="$WHISPER_STACK"
START_STACK="$CANARY_STACK"
HEALTH_KIND="http"
HEALTH_URL="http://$MINERVA_HOST:$CANARY_HOST_PORT/health"
HEALTH_DESC="HTTP $HEALTH_URL"
fi
echo ">>> [2/6] $MINERVA_HOST: stop $STOP_STACK"
ssh "$MINERVA_HOST" "cd '$STOP_STACK' && docker compose stop"
# ---- Step 3 (canary only): unload Ollama models. --------------------------
# Ollama keeps the active model warm for `keep_alive` seconds (300 in
# settings.toml at the time of writing). When the operator switches to
# canary in the middle of that window, the model is still resident and
# canary OOMs on the last few MB. We trigger a synchronous unload via
# the Ollama API: posting `keep_alive: 0` with an empty prompt drops
# the model immediately and frees VRAM. (See ollama/ollama issue #2546.)
unload_ollama_models() {
local base="http://$MINERVA_HOST:$OLLAMA_HOST_PORT"
local ps_file
ps_file=$(mktemp)
trap 'rm -f "$ps_file"' RETURN
if ! curl -fsS --max-time 5 "$base/api/ps" -o "$ps_file"; then
echo " Ollama API unreachable at $base/api/ps — skipping unload" >&2
return 0
fi
# Parse the JSON model list with python3 (avoid jq as a hard dep).
local models
models=$(python3 -c "
import json, sys
try:
d = json.load(open('$ps_file'))
for m in d.get('models', []):
print(m.get('name', ''))
except Exception:
sys.exit(0)
")
if [[ -z "$models" ]]; then
echo " Ollama: no models loaded — VRAM already free"
return 0
fi
while IFS= read -r name; do
[[ -z "$name" ]] && continue
echo " unloading: $name"
curl -fsS --max-time 10 "$base/api/generate" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"$name\",\"prompt\":\"\",\"keep_alive\":0}" \
-o /dev/null \
|| echo " (warn: unload request for $name returned non-2xx)" >&2
done <<< "$models"
# Give the GPU driver a moment to actually reclaim the pages before
# canary starts allocating its own.
sleep 2
}
if [[ "$BACKEND" == "canary" ]]; then
echo ">>> [3/6] freeing VRAM: unloading Ollama models BEFORE canary starts"
unload_ollama_models
else
echo ">>> [3/6] (whisper) Ollama coexistence is fine — skipping unload"
fi
# ---- Step 4: NOW start the chosen stack. ----------------------------------
# For canary this is the critical moment — the GPU has just been freed
# by step 3, and canary's container will allocate into an empty GPU
# instead of fighting Ollama for the last 20 MB.
echo ">>> [4/6] $MINERVA_HOST: start $START_STACK"
ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose up -d"
# ---- Step 5: poll until the chosen backend answers. -----------------------
echo ">>> [5/6] waiting for $HEALTH_DESC (up to ${HEALTH_TIMEOUT_SECS}s)"
probe_once() {
if [[ "$HEALTH_KIND" == "tcp" ]]; then
if (exec 3<>"/dev/tcp/$MINERVA_HOST/$HEALTH_PORT") 2>/dev/null; then
exec 3<&- 3>&-
return 0
fi
return 1
else
curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null 2>&1
fi
}
start=$(date +%s)
deadline=$(( start + HEALTH_TIMEOUT_SECS ))
last_log_snapshot=0
attempt=0
while :; do
if probe_once; then
echo ""
echo " backend is up after $(( $(date +%s) - start ))s"
break
fi
attempt=$(( attempt + 1 ))
printf '.'
now=$(date +%s)
if (( now >= deadline )); then
echo ""
echo "timeout waiting for $HEALTH_DESC after ${HEALTH_TIMEOUT_SECS}s" >&2
echo "--- container logs (tail 40) ---" >&2
ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose logs --tail 40" >&2 || true
exit 1
fi
# Periodic log snapshot: every LOG_SNAPSHOT_SECS, print elapsed time
# and the last few log lines so the operator can SEE if the container
# is loading, crashing, or stalled.
if (( now - last_log_snapshot >= LOG_SNAPSHOT_SECS )); then
echo ""
elapsed=$(( now - start ))
echo " [${elapsed}s elapsed] still waiting; recent container log:"
ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose logs --tail 5 --no-log-prefix 2>&1" \
| sed 's/^/ | /' \
|| echo " (could not read logs)" >&2
last_log_snapshot=$now
fi
sleep "$HEALTH_POLL_SECS"
done
# ---- Step 6: start the server. --------------------------------------------
# dotenvy::dotenv() in server/src/main.rs does NOT override env vars that
# are already set, so this export wins over any ASR_BACKEND= in .env.
echo ">>> [6/6] starting doctate-server (ASR_BACKEND=$BACKEND)"
export ASR_BACKEND="$BACKEND"
exec cargo run -p doctate-server
+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:// # Set to false for plain-HTTP local dev; browsers refuse Secure cookies on http://
COOKIE_SECURE=true 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) # Optional filesystem resources (uncomment if used)
# VOCAB_DIR=./vocab # VOCAB_DIR=./vocab
# HUNSPELL_DICT=/usr/share/hunspell/de_DE.dic # HUNSPELL_DICT=/usr/share/hunspell/de_DE.dic
+7
View File
@@ -8,9 +8,16 @@ transcript_days = 30
document_days = 0 document_days = 0
[whisper] [whisper]
# Active when ASR_BACKEND=whisper (default).
url = "http://localhost:9000" url = "http://localhost:9000"
timeout_seconds = 120 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] [ollama]
url = "http://localhost:11434" url = "http://localhost:11434"
model = "gemma3:4b" model = "gemma3:4b"
+77 -23
View File
@@ -3,15 +3,6 @@ use std::path::PathBuf;
use serde::Deserialize; 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. /// Per-user retention settings from users.toml.
/// ///
/// `auto_close_days = 0` disables the auto-close sweep (a case is never /// `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 api_key: String,
pub web_password: String, pub web_password: String,
pub role: 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)] #[serde(default)]
pub whisper: WhisperUserSettings, pub language: Option<String>,
#[serde(default)] #[serde(default)]
pub retention: RetentionUserSettings, pub retention: RetentionUserSettings,
/// Time window (hours) that defines which cases are visible to this /// Time window (hours) that defines which cases are visible to this
@@ -75,6 +69,31 @@ struct UsersFile {
user: Vec<User>, 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 /// Bootstrap config loaded once from `.env` at startup. Runtime-tunable
/// values (retention, whisper/ollama/llm endpoints, prompts) live in /// values (retention, whisper/ollama/llm endpoints, prompts) live in
/// `Settings` (loaded from `settings.toml`). /// `Settings` (loaded from `settings.toml`).
@@ -104,6 +123,10 @@ pub struct Config {
/// Set `COOKIE_SECURE=false` only for plain-HTTP local development — /// Set `COOKIE_SECURE=false` only for plain-HTTP local development —
/// browsers refuse `Secure` cookies on non-HTTPS origins. /// browsers refuse `Secure` cookies on non-HTTPS origins.
pub cookie_secure: bool, 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 { impl Config {
@@ -127,6 +150,9 @@ impl Config {
)), )),
session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8),
cookie_secure: optional_env_parsed("COOKIE_SECURE", true), 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(), hunspell_dict_path: PathBuf::new(),
session_timeout_hours: 8, session_timeout_hours: 8,
cookie_secure: false, cookie_secure: false,
asr_backend: AsrBackend::Whisper,
} }
} }
} }
@@ -243,7 +270,7 @@ mod tests {
api_key: api_key.into(), api_key: api_key.into(),
web_password: String::new(), web_password: String::new(),
role: "doctor".into(), role: "doctor".into(),
whisper: WhisperUserSettings::default(), language: None,
retention: RetentionUserSettings::default(), retention: RetentionUserSettings::default(),
window_hours: default_window_hours(), window_hours: default_window_hours(),
preview_lines: default_preview_lines(), preview_lines: default_preview_lines(),
@@ -251,7 +278,7 @@ mod tests {
} }
#[test] #[test]
fn parses_user_without_whisper_block() { fn parses_user_without_language() {
let toml_str = r#" let toml_str = r#"
[[user]] [[user]]
slug = "a" slug = "a"
@@ -261,9 +288,7 @@ mod tests {
"#; "#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let u = &parsed.user[0]; let u = &parsed.user[0];
assert!(u.whisper.language.is_none()); assert!(u.language.is_none());
assert!(u.whisper.hotwords.is_none());
assert!(u.whisper.initial_prompt.is_none());
} }
#[test] #[test]
@@ -348,23 +373,17 @@ mod tests {
} }
#[test] #[test]
fn parses_user_with_full_whisper_block() { fn parses_user_with_explicit_language() {
let toml_str = r#" let toml_str = r#"
[[user]] [[user]]
slug = "a" slug = "a"
api_key = "k1" api_key = "k1"
web_password = "" web_password = ""
role = "doctor" role = "doctor"
[user.whisper]
language = "de" language = "de"
hotwords = "HOCM Valsalva"
initial_prompt = "Kardiologie"
"#; "#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let w = &parsed.user[0].whisper; assert_eq!(parsed.user[0].language.as_deref(), Some("de"));
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"));
} }
#[test] #[test]
@@ -430,4 +449,39 @@ mod tests {
let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let parsed: UsersFile = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.user[0].window_hours, 168); 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::AppState;
use doctate_server::analyze; use doctate_server::analyze;
use doctate_server::config::Config; use doctate_server::config::{AsrBackend, Config};
use doctate_server::events; use doctate_server::events;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::settings::Settings; 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!( info!(
prompt_chars = settings.llm.system_prompt.chars().count(), prompt_chars = settings.llm.system_prompt.chars().count(),
"system prompt loaded" "system prompt loaded"
+40
View File
@@ -13,6 +13,8 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub whisper: WhisperSettings, pub whisper: WhisperSettings,
#[serde(default)] #[serde(default)]
pub canary: CanarySettings,
#[serde(default)]
pub ollama: OllamaSettings, pub ollama: OllamaSettings,
#[serde(default)] #[serde(default)]
pub llm: LlmSettings, pub llm: LlmSettings,
@@ -72,6 +74,36 @@ fn default_whisper_timeout_seconds() -> u64 {
120 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)] #[derive(Debug, Clone, Deserialize)]
pub struct OllamaSettings { pub struct OllamaSettings {
#[serde(default = "default_ollama_url")] #[serde(default = "default_ollama_url")]
@@ -180,6 +212,10 @@ mod tests {
url = "http://h:1" url = "http://h:1"
timeout_seconds = 60 timeout_seconds = 60
[canary]
url = "http://h:9"
timeout_seconds = 240
[ollama] [ollama]
url = "http://h:2" url = "http://h:2"
model = "x" model = "x"
@@ -197,6 +233,8 @@ system_prompt = "p"
.unwrap(); .unwrap();
assert_eq!(s.whisper.url, "http://h:1"); assert_eq!(s.whisper.url, "http://h:1");
assert_eq!(s.whisper.timeout_seconds, 60); 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.ollama.keep_alive, 5);
assert_eq!(s.llm.api_key, "k"); assert_eq!(s.llm.api_key, "k");
assert_eq!(s.llm.system_prompt, "p"); assert_eq!(s.llm.system_prompt, "p");
@@ -207,6 +245,8 @@ system_prompt = "p"
let s: Settings = toml::from_str("").unwrap(); let s: Settings = toml::from_str("").unwrap();
assert_eq!(s.whisper.url, "http://localhost:10300"); assert_eq!(s.whisper.url, "http://localhost:10300");
assert_eq!(s.whisper.timeout_seconds, 120); 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.url, "http://localhost:11434");
assert_eq!(s.ollama.model, "gemma3:4b"); assert_eq!(s.ollama.model, "gemma3:4b");
assert_eq!(s.llm.timeout_seconds, 180); 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; use tokio::sync::mpsc;
pub mod canary;
pub mod ffmpeg; pub mod ffmpeg;
pub mod ollama; pub mod ollama;
pub mod recovery; pub mod recovery;
@@ -14,7 +15,7 @@ pub const QUEUE_DEPTH: usize = 64;
pub struct TranscribeJob { pub struct TranscribeJob {
pub audio_path: PathBuf, pub audio_path: PathBuf,
/// Slug of the user who owns this recording. Used by the worker to look up /// 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, pub user_slug: String,
} }
+8 -25
View File
@@ -5,8 +5,6 @@ use doctate_common::join_url;
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
use tracing::debug; use tracing::debug;
use crate::config::WhisperUserSettings;
#[derive(Debug)] #[derive(Debug)]
pub enum WhisperError { pub enum WhisperError {
Io(std::io::Error), Io(std::io::Error),
@@ -59,21 +57,17 @@ impl WhisperError {
/// POST the audio file to whisper-asr-webservice and return the transcript text. /// 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 /// `output=txt` → plain-text response. `language` pins the language so the
/// skips detection (defaults to `de` when the user has no override). We send /// model skips detection (defaults to `de` when the caller passes `None` or
/// AAC-in-mp4 and let the service decode it internally (the default /// an empty string). We send AAC-in-mp4 and let the service decode it
/// `encode=true`) — passing `encode=false` would tell the service "this is /// internally (the default `encode=true`) — passing `encode=false` would
/// already raw PCM", which ours isn't. /// 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()`.
pub async fn transcribe( pub async fn transcribe(
client: &reqwest::Client, client: &reqwest::Client,
whisper_url: &str, whisper_url: &str,
audio: &Path, audio: &Path,
timeout: Duration, timeout: Duration,
settings: &WhisperUserSettings, language: Option<&str>,
) -> Result<String, WhisperError> { ) -> Result<String, WhisperError> {
let filename = audio let filename = audio
.file_name() .file_name()
@@ -86,20 +80,9 @@ pub async fn transcribe(
.file_name(filename) .file_name(filename)
.mime_str("audio/mp4") .mime_str("audio/mp4")
.map_err(WhisperError::Http)?; .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()) { let language = language.filter(|s| !s.is_empty()).unwrap_or("de");
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 url = format!( let url = format!(
"{}?output=txt&language={language}", "{}?output=txt&language={language}",
join_url(whisper_url, "/asr") join_url(whisper_url, "/asr")
+83 -26
View File
@@ -8,8 +8,8 @@ use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use super::{TranscribeReceiver, ffmpeg, ollama, whisper}; use super::{TranscribeReceiver, canary, ffmpeg, ollama, whisper};
use crate::config::{Config, WhisperUserSettings}; use crate::config::{AsrBackend, Config};
use crate::events::{self, CaseEventKind, EventSender}; use crate::events::{self, CaseEventKind, EventSender};
use crate::gazetteer::Gazetteer; use crate::gazetteer::Gazetteer;
use crate::loudness; use crate::loudness;
@@ -20,8 +20,36 @@ use crate::{BusyGuard, WorkerBusy};
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60); const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
/// Consume transcription jobs sequentially. Whisper holds the GPU, so parallelism /// Backend-agnostic ASR error wrapper. Lets the worker handle Whisper
/// here would only cause contention. One job in flight at a time. /// 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)] #[allow(clippy::too_many_arguments)]
pub async fn run( pub async fn run(
mut rx: TranscribeReceiver, mut rx: TranscribeReceiver,
@@ -34,7 +62,23 @@ pub async fn run(
oneliner_locks: OnelinerLocks, oneliner_locks: OnelinerLocks,
) { ) {
info!("Transcription worker started"); 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 { while let Some(job) = rx.recv().await {
let _guard = BusyGuard::new(worker_busy.clone()); let _guard = BusyGuard::new(worker_busy.clone());
@@ -49,15 +93,14 @@ pub async fn run(
continue; 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. // edits to users.toml (on next restart) reach in-flight jobs naturally.
// Unknown slug → empty settings (service falls back to language=de). // Unknown slug or missing field → None (service falls back to "de").
let user_whisper: WhisperUserSettings = config let user_language: Option<String> = config
.users .users
.iter() .iter()
.find(|u| u.slug == job.user_slug) .find(|u| u.slug == job.user_slug)
.map(|u| u.whisper.clone()) .and_then(|u| u.language.clone());
.unwrap_or_default();
info!(audio = %audio_path.display(), user = %job.user_slug, "Transcribing"); 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 // Run audio analysis (duration + volumedetect) and the active
// concurrently against the remuxed tempfile. The volumedetect // ASR backend concurrently against the remuxed tempfile. The
// pass is decode-bound on a CPU core (~100-500 ms for typical // volumedetect pass is decode-bound on a CPU core (~100-500 ms
// dictation lengths); Whisper dominates the wallclock with a // for typical dictation lengths); the ASR call dominates the
// GPU/network round-trip in seconds, so the analysis cost // wallclock with a GPU/network round-trip in seconds, so the
// disappears behind the join. // analysis cost disappears behind the join.
// //
// `tokio::join!` (not `try_join!`) intentionally awaits both // `tokio::join!` (not `try_join!`) intentionally awaits both
// futures even on error: a failed analysis must not throw // 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 // drop any analysis values — without a meta sidecar the
// page-load heal re-enqueues, and that retry must run the // page-load heal re-enqueues, and that retry must run the
// analysis fresh. // analysis fresh.
let (analysis_result, whisper_result) = tokio::join!( let asr_future = async {
ffmpeg::analyze_audio(remuxed.path()), match backend {
whisper::transcribe( AsrBackend::Whisper => whisper::transcribe(
&client, &client,
&settings.whisper.url, &asr_url,
remuxed.path(), remuxed.path(),
timeout, timeout,
&user_whisper, 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. // write any meta, regardless of whether the analysis succeeded.
let text = match whisper_result { let text = match asr_result {
Ok(t) => t, Ok(t) => t,
Err(e) => { 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() { if e.is_transient() {
// Leave the recording as plain `.m4a` (no `.failed` suffix) // Leave the recording as plain `.m4a` (no `.failed` suffix)
// so the page-load heal (`enqueue_pending_for_user`) picks // 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}"), api_key: format!("key-{slug}"),
web_password: hash_password(TEST_PASSWORD), web_password: hash_password(TEST_PASSWORD),
role: "doctor".into(), role: "doctor".into(),
whisper: Default::default(), language: None,
retention: Default::default(), retention: Default::default(),
window_hours: 72, window_hours: 72,
preview_lines: 2, preview_lines: 2,
+5 -64
View File
@@ -4,7 +4,6 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use doctate_server::config::WhisperUserSettings;
use doctate_server::transcribe; use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner}; use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
@@ -79,7 +78,7 @@ async fn whisper_client_posts_multipart_and_returns_text() {
&server.uri(), &server.uri(),
&fixture("sample.m4a"), &fixture("sample.m4a"),
Duration::from_secs(10), Duration::from_secs(10),
&WhisperUserSettings::default(), None,
) )
.await .await
.expect("transcribe failed"); .expect("transcribe failed");
@@ -103,7 +102,7 @@ async fn whisper_client_returns_status_error_on_500() {
&server.uri(), &server.uri(),
&fixture("sample.m4a"), &fixture("sample.m4a"),
Duration::from_secs(10), Duration::from_secs(10),
&WhisperUserSettings::default(), None,
) )
.await .await
.expect_err("expected error"); .expect_err("expected error");
@@ -133,7 +132,7 @@ async fn whisper_client_times_out() {
&server.uri(), &server.uri(),
&fixture("sample.m4a"), &fixture("sample.m4a"),
Duration::from_millis(100), Duration::from_millis(100),
&WhisperUserSettings::default(), None,
) )
.await .await
.expect_err("expected timeout"); .expect_err("expected timeout");
@@ -145,11 +144,9 @@ async fn whisper_client_times_out() {
} }
#[tokio::test] #[tokio::test]
async fn whisper_client_forwards_hotwords_and_prompt_when_set() { async fn whisper_client_forwards_explicit_language() {
let server = MockServer::start().await; 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")) Mock::given(method("POST"))
.and(path("/asr")) .and(path("/asr"))
.and(query_param("language", "en")) .and(query_param("language", "en"))
@@ -159,71 +156,15 @@ async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
.await; .await;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let settings = WhisperUserSettings {
language: Some("en".into()),
hotwords: Some("HOCM Valsalva".into()),
initial_prompt: Some("Kardiologie".into()),
};
transcribe( transcribe(
&client, &client,
&server.uri(), &server.uri(),
&fixture("sample.m4a"), &fixture("sample.m4a"),
Duration::from_secs(10), Duration::from_secs(10),
&settings, Some("en"),
) )
.await .await
.expect("transcribe failed"); .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] #[tokio::test]
+1
View File
@@ -127,6 +127,7 @@ Risperidon
Rivaroxaban Rivaroxaban
Rosuvastatin Rosuvastatin
Salbutamol Salbutamol
Sellink
Sertralin Sertralin
Sildenafil Sildenafil
Simvastatin Simvastatin
+3 -7
View File
@@ -12,7 +12,6 @@ Per fixture, three files share a common stem:
|------------------------|----------|-------------------------------------------------------------------| |------------------------|----------|-------------------------------------------------------------------|
| `<name>.m4a` | yes | Raw AAC recording as produced by `scripts/dictate.sh` / the watch | | `<name>.m4a` | yes | Raw AAC recording as produced by `scripts/dictate.sh` / the watch |
| `<name>.expected.txt` | yes | Golden transcript, hand-reviewed — the regression target | | `<name>.expected.txt` | yes | Golden transcript, hand-reviewed — the regression target |
| `<name>.hotwords.txt` | no | One-line hotwords list if the fixture is meant to test Fachvokabular |
| `<name>.notes.md` | no | Short description: what this case exercises, any gotchas | | `<name>.notes.md` | no | Short description: what this case exercises, any gotchas |
## Naming convention ## Naming convention
@@ -24,8 +23,7 @@ Per fixture, three files share a common stem:
- `ortho_knie_meniskus` - `ortho_knie_meniskus`
- `psych_ptbs_flashbacks` - `psych_ptbs_flashbacks`
The prefix encodes the medical domain so we can group by specialty when we The prefix encodes the medical domain so we can group by specialty.
start per-user hotwords tuning.
## Adding a new fixture ## Adding a new fixture
@@ -36,11 +34,9 @@ start per-user hotwords tuning.
4. Extract the transcript from the recording metadata sidecar: 4. Extract the transcript from the recording metadata sidecar:
`jq -r '.transcript.text' $DATA_PATH/<slug>/open/<case_id>/<timestamp>.json` `jq -r '.transcript.text' $DATA_PATH/<slug>/open/<case_id>/<timestamp>.json`
→ write it to `tests/fixtures/dictations/<name>.expected.txt`. → write it to `tests/fixtures/dictations/<name>.expected.txt`.
5. **Review the transcript** — fix any Whisper errors by hand. This is the 5. **Review the transcript** — fix any ASR errors by hand. This is the
golden reference; accept nothing that is actually wrong. golden reference; accept nothing that is actually wrong.
6. If the fixture tests Fachvokabular, add `<name>.hotwords.txt` with the 6. Commit the files together.
domain-specific words, one space-separated line.
7. Commit all three/four files together.
## What not to commit here ## What not to commit here