From bb584b6ea03d25461189fd9eed0d6b160f0dde67 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 30 Apr 2026 16:56:12 +0200 Subject: [PATCH] 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. --- experiments/src/bin/diff_runs.rs | 7 +- experiments/src/bin/run_full_case.rs | 71 +++------ experiments/src/bin/run_llm_only.rs | 4 +- experiments/src/lib.rs | 12 +- scripts/run-server.sh | 217 +++++++++++++++++++++++++++ server/.env.example | 7 + server/settings.toml.example | 7 + server/src/config.rs | 102 ++++++++++--- server/src/main.rs | 11 +- server/src/settings.rs | 40 +++++ server/src/transcribe/canary.rs | 143 ++++++++++++++++++ server/src/transcribe/mod.rs | 3 +- server/src/transcribe/whisper.rs | 33 +--- server/src/transcribe/worker.rs | 115 ++++++++++---- server/tests/common/users.rs | 2 +- server/tests/transcribe_test.rs | 69 +-------- server/vocab/vocabulary.txt | 1 + tests/fixtures/dictations/README.md | 10 +- 18 files changed, 639 insertions(+), 215 deletions(-) create mode 100755 scripts/run-server.sh create mode 100644 server/src/transcribe/canary.rs diff --git a/experiments/src/bin/diff_runs.rs b/experiments/src/bin/diff_runs.rs index 767a24f..04def27 100644 --- a/experiments/src/bin/diff_runs.rs +++ b/experiments/src/bin/diff_runs.rs @@ -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 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 model = v.get("llm_model").and_then(|x| x.as_str()).unwrap_or("?"); let wallclock = v .pointer("/timings_ms/wallclock_ms") .and_then(|x| x.as_u64()) .unwrap_or(0); - println!(" whisper={w} hotwords={h} llm={l} model={model} wallclock={wallclock}ms"); + println!(" llm={l} model={model} wallclock={wallclock}ms"); Ok(()) } diff --git a/experiments/src/bin/run_full_case.rs b/experiments/src/bin/run_full_case.rs index b44b4e8..93ea586 100644 --- a/experiments/src/bin/run_full_case.rs +++ b/experiments/src/bin/run_full_case.rs @@ -6,14 +6,12 @@ //! Usage: //! run_full_case \ //! --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 \ //! --runs 3 //! //! Path conventions: //! - `--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` //! - `--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::prompt::render_prompt; use doctate_server::analyze::{AnalysisInput, RecordingInput}; -use doctate_server::config::WhisperUserSettings; use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; use doctate_server::settings::Settings; use doctate_server::transcribe::ffmpeg::remux_faststart; @@ -42,17 +39,6 @@ struct Args { #[arg(long)] 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, - /// Path to the LLM system prompt file. Typically under `prompts/llm/`. #[arg(long)] 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 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?; info!( vocab_dir = %args.vocab_dir.display(), @@ -129,14 +104,12 @@ async fn main() -> Result<()> { .build() .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 runs_root = data_runs_root(&case_dir)?; 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); std::fs::create_dir_all(run_dir.join("transcripts"))?; @@ -149,21 +122,18 @@ async fn main() -> Result<()> { for audio in &audios { let recorded_at = stem(audio); let t_whisper = Instant::now(); - let raw = transcribe_one( - &http, - &settings, - &user_whisper, - audio, - ) - .await - .with_context(|| format!("transcribing {}", audio.display()))?; + let raw = transcribe_one(&http, &settings, Some("de"), audio) + .await + .with_context(|| format!("transcribing {}", audio.display()))?; timings.whisper_total_ms += t_whisper.elapsed().as_millis() as u64; let t_gaz = Instant::now(); let cleaned = gazetteer.replace(&raw); 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)?; recordings_meta.push(RecordingMeta { @@ -191,7 +161,8 @@ async fn main() -> Result<()> { if !args.no_llm { 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; // Post-LLM gazetteer pass — mirrors `server/src/analyze/worker.rs:150`. // Catches LLM-drift back to non-canonical forms (e.g. anglicization) @@ -212,8 +183,6 @@ async fn main() -> Result<()> { .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(), - whisper_variant: whisper_variant_id.clone(), - whisper_hotwords_variant: hotwords_variant_id.clone(), llm_variant: llm_variant_id.clone(), whisper_url: settings.whisper.url.clone(), llm_url: settings.llm.url.clone(), @@ -280,9 +249,9 @@ async fn load_gazetteer( fn dict_mode_clone(m: &GazetteerDictMode) -> GazetteerDictMode { match m { GazetteerDictMode::NoDict => GazetteerDictMode::NoDict, - GazetteerDictMode::SpellbookHunspell { stem } => GazetteerDictMode::SpellbookHunspell { - stem: stem.clone(), - }, + GazetteerDictMode::SpellbookHunspell { stem } => { + GazetteerDictMode::SpellbookHunspell { stem: stem.clone() } + } } } @@ -325,16 +294,22 @@ fn stem>(p: P) -> String { async fn transcribe_one( http: &reqwest::Client, settings: &Settings, - user_whisper: &WhisperUserSettings, + language: Option<&str>, audio: &Path, ) -> Result { let remuxed = remux_faststart(audio) .await .with_context(|| format!("ffmpeg remux failed for {}", audio.display()))?; let timeout = Duration::from_secs(settings.whisper.timeout_seconds); - let text = srv_whisper::transcribe(http, &settings.whisper.url, remuxed.path(), timeout, user_whisper) - .await - .map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?; + let text = srv_whisper::transcribe( + http, + &settings.whisper.url, + remuxed.path(), + timeout, + language, + ) + .await + .map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?; Ok(text) } diff --git a/experiments/src/bin/run_llm_only.rs b/experiments/src/bin/run_llm_only.rs index d1e66bd..022dd56 100644 --- a/experiments/src/bin/run_llm_only.rs +++ b/experiments/src/bin/run_llm_only.rs @@ -119,7 +119,7 @@ async fn main() -> Result<()> { let runs_root = data_runs_root(&case_dir)?; 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); std::fs::create_dir_all(&run_dir)?; @@ -147,8 +147,6 @@ async fn main() -> Result<()> { .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(), - whisper_variant: "none".into(), - whisper_hotwords_variant: None, llm_variant: llm_variant_id.clone(), whisper_url: String::new(), llm_url: settings.llm.url.clone(), diff --git a/experiments/src/lib.rs b/experiments/src/lib.rs index c61694e..2a010e0 100644 --- a/experiments/src/lib.rs +++ b/experiments/src/lib.rs @@ -17,13 +17,15 @@ use time::OffsetDateTime; use time::format_description::well_known::Iso8601; /// Generate a run identifier in the form -/// `_w-_l-_run`. Used as the directory name under +/// `_l-_run`. Used as the directory name under /// `case_/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 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('.', "-"); - 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 @@ -61,8 +63,6 @@ pub struct RunMeta { pub run_id: String, pub created_at_utc: String, pub case: String, - pub whisper_variant: String, - pub whisper_hotwords_variant: Option, pub llm_variant: String, pub whisper_url: String, pub llm_url: String, diff --git a/scripts/run-server.sh b/scripts/run-server.sh new file mode 100755 index 0000000..7040d1a --- /dev/null +++ b/scripts/run-server.sh @@ -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 <&2 +Usage: $0 + +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 diff --git a/server/.env.example b/server/.env.example index 17cff46..3807620 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/settings.toml.example b/server/settings.toml.example index ae17465..4d7c398 100644 --- a/server/settings.toml.example +++ b/server/settings.toml.example @@ -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" diff --git a/server/src/config.rs b/server/src/config.rs index bbe9108..1cbc19b 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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, - pub hotwords: Option, - pub initial_prompt: Option, -} - /// 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, #[serde(default)] pub retention: RetentionUserSettings, /// Time window (hours) that defines which cases are visible to this @@ -75,6 +69,31 @@ struct UsersFile { user: Vec, } +/// 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 { + 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::() + .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::().unwrap(), + AsrBackend::Whisper + ); + } + + #[test] + fn asr_backend_parses_canary() { + assert_eq!("canary".parse::().unwrap(), AsrBackend::Canary); + } + + #[test] + fn asr_backend_parses_case_insensitive_with_whitespace() { + assert_eq!( + " Whisper ".parse::().unwrap(), + AsrBackend::Whisper + ); + assert_eq!("CANARY".parse::().unwrap(), AsrBackend::Canary); + } + + #[test] + fn asr_backend_rejects_invalid() { + let err = "wav2vec".parse::().unwrap_err(); + assert!(err.contains("wav2vec"), "got: {err}"); + } } diff --git a/server/src/main.rs b/server/src/main.rs index 234bc90..76f5c31 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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" diff --git a/server/src/settings.rs b/server/src/settings.rs index dd79d4d..cfb6dcc 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -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); diff --git a/server/src/transcribe/canary.rs b/server/src/transcribe/canary.rs new file mode 100644 index 0000000..1e1e7e3 --- /dev/null +++ b/server/src/transcribe/canary.rs @@ -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 { + 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()); + } +} diff --git a/server/src/transcribe/mod.rs b/server/src/transcribe/mod.rs index 96b25f2..03e69dd 100644 --- a/server/src/transcribe/mod.rs +++ b/server/src/transcribe/mod.rs @@ -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, } diff --git a/server/src/transcribe/whisper.rs b/server/src/transcribe/whisper.rs index 59e69b2..e83c96e 100644 --- a/server/src/transcribe/whisper.rs +++ b/server/src/transcribe/whisper.rs @@ -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 { 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") diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index c5586c7..7501fb1 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -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 = 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 diff --git a/server/tests/common/users.rs b/server/tests/common/users.rs index 86588d0..e6b241c 100644 --- a/server/tests/common/users.rs +++ b/server/tests/common/users.rs @@ -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, diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 9976e0c..2fb8a44 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -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] diff --git a/server/vocab/vocabulary.txt b/server/vocab/vocabulary.txt index 10b7e30..4d53d79 100644 --- a/server/vocab/vocabulary.txt +++ b/server/vocab/vocabulary.txt @@ -127,6 +127,7 @@ Risperidon Rivaroxaban Rosuvastatin Salbutamol +Sellink Sertralin Sildenafil Simvastatin diff --git a/tests/fixtures/dictations/README.md b/tests/fixtures/dictations/README.md index 10794cf..704f542 100644 --- a/tests/fixtures/dictations/README.md +++ b/tests/fixtures/dictations/README.md @@ -12,7 +12,6 @@ Per fixture, three files share a common stem: |------------------------|----------|-------------------------------------------------------------------| | `.m4a` | yes | Raw AAC recording as produced by `scripts/dictate.sh` / the watch | | `.expected.txt` | yes | Golden transcript, hand-reviewed — the regression target | -| `.hotwords.txt` | no | One-line hotwords list if the fixture is meant to test Fachvokabular | | `.notes.md` | no | Short description: what this case exercises, any gotchas | ## Naming convention @@ -24,8 +23,7 @@ Per fixture, three files share a common stem: - `ortho_knie_meniskus` - `psych_ptbs_flashbacks` -The prefix encodes the medical domain so we can group by specialty when we -start per-user hotwords tuning. +The prefix encodes the medical domain so we can group by specialty. ## Adding a new fixture @@ -36,11 +34,9 @@ start per-user hotwords tuning. 4. Extract the transcript from the recording metadata sidecar: `jq -r '.transcript.text' $DATA_PATH//open//.json` → write it to `tests/fixtures/dictations/.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. -6. If the fixture tests Fachvokabular, add `.hotwords.txt` with the - domain-specific words, one space-separated line. -7. Commit all three/four files together. +6. Commit the files together. ## What not to commit here