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:
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 +122,18 @@ 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,
|
.await
|
||||||
&settings,
|
.with_context(|| format!("transcribing {}", audio.display()))?;
|
||||||
&user_whisper,
|
|
||||||
audio,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.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;
|
||||||
|
|
||||||
let t_gaz = Instant::now();
|
let t_gaz = Instant::now();
|
||||||
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,16 +294,22 @@ 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(
|
||||||
.await
|
http,
|
||||||
.map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?;
|
&settings.whisper.url,
|
||||||
|
remuxed.path(),
|
||||||
|
timeout,
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?;
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Executable
+217
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+78
-24
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use doctate_common::join_url;
|
||||||
|
use reqwest::multipart::{Form, Part};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CanaryError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Http(reqwest::Error),
|
||||||
|
Status { status: u16, body: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CanaryError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(e) => write!(f, "canary io error: {e}"),
|
||||||
|
Self::Http(e) => write!(f, "canary http error: {e}"),
|
||||||
|
Self::Status { status, body } => {
|
||||||
|
write!(f, "canary returned {status}: {body}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CanaryError {}
|
||||||
|
|
||||||
|
impl CanaryError {
|
||||||
|
/// Mirrors `WhisperError::is_transient`: `Http` (network), 5xx/408/429
|
||||||
|
/// → transient (page-load heal re-enqueues); `Io` and other 4xx →
|
||||||
|
/// permanent (rename to `.m4a.failed`).
|
||||||
|
pub fn is_transient(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Http(_) => true,
|
||||||
|
Self::Status { status, .. } => {
|
||||||
|
*status == 408 || *status == 429 || (500..=599).contains(status)
|
||||||
|
}
|
||||||
|
Self::Io(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST the audio file to the Canary `/inference` endpoint and return the
|
||||||
|
/// transcript text.
|
||||||
|
///
|
||||||
|
/// Form fields per docs/canary.md §2.4:
|
||||||
|
/// - `file`: the multipart upload (any container — Canary's service
|
||||||
|
/// ffmpegs to 16 kHz mono internally).
|
||||||
|
/// - `language`: defaults to `de` when the caller passes `None` or empty.
|
||||||
|
/// - `pnc=yes`: Punctuation and Capitalization, matching the Whisper path.
|
||||||
|
/// - `timestamps=no`: pipeline does not consume word/segment timestamps.
|
||||||
|
/// - `response_format=text`: plain-text body, structurally identical to
|
||||||
|
/// Whisper's `output=txt` response — no downstream parsing needed.
|
||||||
|
pub async fn transcribe(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
canary_url: &str,
|
||||||
|
audio: &Path,
|
||||||
|
timeout: Duration,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<String, CanaryError> {
|
||||||
|
let filename = audio
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "audio.m4a".to_string());
|
||||||
|
|
||||||
|
let bytes = tokio::fs::read(audio).await.map_err(CanaryError::Io)?;
|
||||||
|
|
||||||
|
let part = Part::bytes(bytes)
|
||||||
|
.file_name(filename)
|
||||||
|
.mime_str("audio/mp4")
|
||||||
|
.map_err(CanaryError::Http)?;
|
||||||
|
let lang = language.filter(|s| !s.is_empty()).unwrap_or("de");
|
||||||
|
let form = Form::new()
|
||||||
|
.part("file", part)
|
||||||
|
.text("language", lang.to_owned())
|
||||||
|
.text("pnc", "yes")
|
||||||
|
.text("timestamps", "no")
|
||||||
|
.text("response_format", "text");
|
||||||
|
|
||||||
|
let url = join_url(canary_url, "/inference");
|
||||||
|
debug!(%url, language = lang, "posting to canary");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.timeout(timeout)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(CanaryError::Http)?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.map_err(CanaryError::Http)?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(CanaryError::Status {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_transient_classifies_status_codes() {
|
||||||
|
let cases: &[(u16, bool)] = &[
|
||||||
|
(400, false),
|
||||||
|
(401, false),
|
||||||
|
(403, false),
|
||||||
|
(404, false),
|
||||||
|
(408, true),
|
||||||
|
(415, false),
|
||||||
|
(422, false),
|
||||||
|
(429, true),
|
||||||
|
(500, true),
|
||||||
|
(502, true),
|
||||||
|
(503, true),
|
||||||
|
(504, true),
|
||||||
|
];
|
||||||
|
for &(status, expected) in cases {
|
||||||
|
let err = CanaryError::Status {
|
||||||
|
status,
|
||||||
|
body: String::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
err.is_transient(),
|
||||||
|
expected,
|
||||||
|
"status {status} expected is_transient={expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_transient_io_is_permanent() {
|
||||||
|
let err = CanaryError::Io(std::io::Error::other("disk gone"));
|
||||||
|
assert!(!err.is_transient());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use tokio::sync::mpsc;
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ Risperidon
|
|||||||
Rivaroxaban
|
Rivaroxaban
|
||||||
Rosuvastatin
|
Rosuvastatin
|
||||||
Salbutamol
|
Salbutamol
|
||||||
|
Sellink
|
||||||
Sertralin
|
Sertralin
|
||||||
Sildenafil
|
Sildenafil
|
||||||
Simvastatin
|
Simvastatin
|
||||||
|
|||||||
Vendored
+3
-7
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user