Add experiments sandbox: prompt iteration tooling

Nested Cargo-Workspace unter experiments/ als Pfad-Dependency auf
../server. Reproduziert die Production-Pipeline 1:1 durch direkte
Wiederverwendung der Server-Module (transcribe::whisper, analyze::llm,
gazetteer, ffmpeg-Remux) — kein eigenes HTTP-Re-Implementieren, kein
Drift-Risiko zur Live-Pipeline.

Drei bin-Targets:
- run_full_case: volle Pipeline (ffmpeg → whisper → pre-gazetteer → llm
  → post-gazetteer), n Runs einer Variante
- run_llm_only: schneller LLM-Iterations-Pfad auf existierenden,
  gazetteer-bereinigten Transkripten — spart Whisper-Calls
- diff_runs: qualitativer Vergleich zwischen zwei Run-Verzeichnissen

Repo-Hygiene: case_*/ (Patientendaten, Symlinks zu tmpdata) und _data/
(Run-Outputs) sind komplett gitignored. Nur Code und fall-übergreifende
Prompt-Hypothesen (prompts/) werden versioniert.

Erste Prompt-Varianten zur Treue-Klausel und Konflikt-Auflösung:
prompts/llm/v1_treue.txt, v2_treue.txt.
This commit is contained in:
2026-04-27 22:43:02 +02:00
parent 99f77b666d
commit 10d0a6ba94
13 changed files with 3671 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/target/
/_data/
# Fall-spezifische Inputs (Audios, Diktat-Text, Befund-Snapshots,
# vocab-Test-Erweiterungen) bleiben strikt lokal — Patientendaten und
# nicht-portable Symlinks gehören nicht ins Repo.
/case_*/
+2732
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[workspace]
resolver = "3"
[package]
name = "doctate-experiments"
version = "0.1.0"
edition = "2024"
[dependencies]
doctate-server = { path = "../server" }
doctate-common = { path = "../doctate-common" }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
time = { version = "0.3", features = ["formatting", "macros"] }
similar = "2"
tempfile = "3"
[[bin]]
name = "run_full_case"
path = "src/bin/run_full_case.rs"
[[bin]]
name = "run_llm_only"
path = "src/bin/run_llm_only.rs"
[[bin]]
name = "diff_runs"
path = "src/bin/diff_runs.rs"
+15
View File
@@ -0,0 +1,15 @@
Du bist ein medizinischer Assistent.
Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.
WICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler wie phonetisch ähnliche Verwechslungen und zerschnittene Komposita (z.B. "Spolade" statt "Schokolade", "posbrandial" statt "postprandial", "in Faust" statt "infaust"). Korrigiere diese Fehler AKTIV. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei und markiere es.
DOSIERUNGSSCHEMA: 34 kleine Zahlen im Medikationskontext sind morgens-mittags-abends(-nachts). Schreibe kompakt: "eins null zwei" → "1-0-2", "eins null zwei null" → "1-0-2-0". Nur bei klarem Medikationskontext anwenden, sonst Original behalten und markieren.
MARKIERUNGEN IM FORMAT "==text==" signalisieren dem Arzt "bitte prüfen".
Markiere jede korrigierte oder zweifelhafte Stelle.
Markiere zusätzlich: Zahlen ohne klaren Kontext oder Einheit.
Markiere sparsam — geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert.
WICHTIG: Es sind keine anderen Arten von Annotationen erlaubt (z.B. "(unsicher)", "[TODO]").
+23
View File
@@ -0,0 +1,23 @@
Du bist ein medizinischer Assistent.
Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.
WICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler wie phonetisch ähnliche Verwechslungen und zerschnittene Komposita (z.B. "Spolade" statt "Schokolade", "posbrandial" statt "postprandial", "in Faust" statt "infaust"). Korrigiere diese Fehler AKTIV. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei und markiere es.
TREUE ZUM DIKTAT (höchste Priorität — vor allen anderen Regeln):
- Anatomische Lokalisationen, Werte mit Einheiten, Wirkstoffnamen, Dosierungen und genannte Befunde NIEMALS umformulieren, eindampfen oder weglassen. Wörtlich übernehmen.
- KEINE medizinisch klingenden Komposita aus phonetischer Nähe konstruieren. Wenn ein Token kein klares Wort ergibt: markiere es mit ==...== und lass den Rohinhalt stehen, statt zu raten. Lieber ein markiertes Fragment als ein erfundener Begriff.
- KEINE neuen Tatsachen hinzufügen, auch nicht naheliegende medizinische Interpretationen ("Beschwerden" → NICHT "Brustbeschwerden", "erhöhter Blutdruck" → NICHT "hypertensive Krise").
- Werte mit Einheiten zusammenhalten ("35 ng/l", nicht nur "35"). Wenn die Einheit im Diktat fehlt: markiere die Zahl.
- Diktat-Reihenfolge erhalten — Befunde aus dem Status-Abschnitt nicht in die Anamnese verschieben, auch wenn sie thematisch verwandt wirken.
DOSIERUNGSSCHEMA: 34 kleine Zahlen im Medikationskontext sind morgens-mittags-abends(-nachts). Schreibe kompakt: "eins null zwei" → "1-0-2", "eins null zwei null" → "1-0-2-0". Nur bei klarem Medikationskontext anwenden, sonst Original behalten und markieren.
MARKIERUNGEN IM FORMAT "==text==" signalisieren dem Arzt "bitte prüfen".
Markiere jede korrigierte oder zweifelhafte Stelle.
Markiere zusätzlich: Zahlen ohne klaren Kontext oder Einheit.
Markiere sparsam — geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert.
WICHTIG: Es sind keine anderen Arten von Annotationen erlaubt (z.B. "(unsicher)", "[TODO]").
+29
View File
@@ -0,0 +1,29 @@
Du bist ein medizinischer Assistent.
Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Fließtext zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.
FORMAT: Ein einziger zusammenhängender Fließtext, mehrere Absätze erlaubt. KEINE Inline-Sektionsmarker (kein "Status:", "Diagnostik:", "Therapie:", "Plan:"), KEINE Markdown-Headings, KEINE Aufzählungspunkte.
WICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler wie phonetisch ähnliche Verwechslungen und zerschnittene Komposita (z.B. "Spolade" statt "Schokolade", "posbrandial" statt "postprandial", "in Faust" statt "infaust"). Korrigiere diese Fehler AKTIV. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei und markiere es.
AKTIVE KORREKTUR (nicht durchreichen): Bekannte Wirkstoffnamen, etablierte medizinische Akronyme und Standard-Termini sind aktiv zu korrigieren, wenn der ASR-Fehler eindeutig ist. Wenn die korrigierte Form ein bekannter Wirkstoff oder Standard-Begriff ist und keine andere medizinische Lesart Sinn ergibt: Korrektur ohne Markierung übernehmen.
TREUE ZUM DIKTAT (höchste Priorität — vor allen anderen Regeln):
- Anatomische Lokalisationen, Werte mit Einheiten, Wirkstoffnamen, Dosierungen und genannte Befunde NIEMALS umformulieren, eindampfen oder weglassen. Wörtlich übernehmen.
- KEINE medizinisch klingenden Komposita aus phonetischer Nähe konstruieren. Wenn ein Token kein klares Wort ergibt: markiere es mit ==...== und lass den Rohinhalt stehen, statt zu raten.
- KEINE neuen Tatsachen hinzufügen, auch nicht naheliegende medizinische Interpretationen.
- Werte mit Einheiten zusammenhalten ("35 ng/l", nicht nur "35"). Wenn die Einheit im Diktat fehlt: markiere die Zahl.
- Diktat-Reihenfolge erhalten — Befunde aus späteren Aufnahmen nicht in die Anamnese verschieben, auch wenn sie thematisch verwandt wirken.
WIDERSPRÜCHE ZWISCHEN AUFNAHMEN: Bei widersprüchlichen Aussagen über mehrere Aufnahmen die chronologisch letzte Aussage übernehmen, die frühere weglassen oder als ==text== markieren. KEINE Verlaufsgeschichte konstruieren (NICHT "die im weiteren Verlauf als X beurteilt wird"). Wenn eine frühere Aufnahme einen offensichtlichen ASR-Fehler enthält und alle späteren Aufnahmen die korrigierte Form zeigen: die spätere als korrekt nehmen, früheren Fehler nicht erwähnen.
DOSIERUNGSSCHEMA: 34 kleine Zahlen im Medikationskontext sind morgens-mittags-abends(-nachts). Schreibe kompakt: "eins null zwei" → "1-0-2", "eins null zwei null" → "1-0-2-0". Nur bei klarem Medikationskontext anwenden, sonst Original behalten und markieren.
MARKIERUNGEN IM FORMAT "==text==" signalisieren dem Arzt "bitte prüfen".
Markiere jede zweifelhafte Stelle, bei der die Korrektur nicht eindeutig ist.
Markiere zusätzlich: Zahlen ohne klaren Kontext oder Einheit.
Markiere sparsam — eindeutig korrigierte Wirkstoffe und geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert.
WICHTIG: Es sind keine anderen Arten von Annotationen erlaubt (z.B. "(unsicher)", "[TODO]").
@@ -0,0 +1 @@
Internistischer Befund: Bisoprolol 5 mg 1-0-0, Ramipril 5 mg 1-0-1, Pantoprazol 40 mg 1-0-0-1, ASS 100 mg 1-0-0.
@@ -0,0 +1,29 @@
# Whisper Hotwords — handle with extreme care
Whisper-Hotwords bias'en das ASR-Modell aggressiv. Ein Hotword, das in
einem konkreten Fall hilft, kann in einem unverwandten Diktat dazu
führen, dass das Modell den Begriff aus phonetischer Ähnlichkeit
**halluziniert** (orthopädischer Befund schreibt plötzlich
„Holosystolikum").
## Bevorzugter Korrekturpfad
`server/vocab/*.txt` (Gazetteer, edit-distance ≤ 2 mit Dict-Veto).
Strukturell halluzinationsfrei, weil Korrekturen nur greifen, wenn
Whisper bereits ein Token erzeugt hat, das phonetisch nah am
Vocabulary-Eintrag liegt.
## Wenn überhaupt Hotwords
- Nur generische Termini aus einem breiten Fall-Korpus
- Nicht aus einem einzelnen Test-Fall heraus optimiert (Curve-Fitting)
- Vor Aktivierung: Negativ-Kontrolle gegen Diktate aus mehreren
Fachbereichen — sucht das Modell nach Halluzinationen, die nur durch
den Hotword-Bias entstehen?
## Datei-Konvention
Hotword-Files in diesem Verzeichnis enthalten **ausschließlich
whitespace-separierte Termini** (kein Markdown, keine Kommentare),
weil der gesamte File-Inhalt 1:1 als `hotwords`-Form-Field an die
Whisper-API gesendet wird.
+121
View File
@@ -0,0 +1,121 @@
//! Side-by-side diff between two runs.
//!
//! Usage:
//! diff_runs <run-dir-A> <run-dir-B>
//!
//! Prints a unified diff of `document.md` and a summary of `==` markers
//! per run, with variant identities pulled from `meta.json`.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use similar::{ChangeTag, TextDiff};
#[derive(Parser, Debug)]
#[command(name = "diff_runs")]
struct Args {
run_a: PathBuf,
run_b: PathBuf,
}
fn main() -> Result<()> {
let args = Args::parse();
println!("=== A: {}", args.run_a.display());
print_meta_summary(&args.run_a)?;
println!("=== B: {}", args.run_b.display());
print_meta_summary(&args.run_b)?;
let doc_a = read_or_empty(&args.run_a.join("document.md"))?;
let doc_b = read_or_empty(&args.run_b.join("document.md"))?;
println!("\n--- document.md diff (A → B) ---");
print_diff(&doc_a, &doc_b);
println!(
"\n=={{}}== markers A: {} B: {}",
count_markers(&doc_a),
count_markers(&doc_b)
);
diff_transcripts(&args.run_a, &args.run_b)?;
Ok(())
}
fn print_meta_summary(run: &Path) -> Result<()> {
let meta_path = run.join("meta.json");
let raw = match std::fs::read_to_string(&meta_path) {
Ok(s) => s,
Err(_) => {
println!(" (no meta.json)");
return Ok(());
}
};
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");
Ok(())
}
fn read_or_empty(p: &Path) -> Result<String> {
Ok(std::fs::read_to_string(p).unwrap_or_default())
}
fn print_diff(a: &str, b: &str) {
let diff = TextDiff::from_lines(a, b);
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Equal => " ",
ChangeTag::Insert => "+ ",
ChangeTag::Delete => "- ",
};
print!("{sign}{change}");
}
}
fn count_markers(text: &str) -> usize {
text.matches("==").count() / 2
}
fn diff_transcripts(a: &Path, b: &Path) -> Result<()> {
let ta = a.join("transcripts");
let tb = b.join("transcripts");
if !ta.exists() || !tb.exists() {
return Ok(());
}
let mut names: Vec<String> = std::fs::read_dir(&ta)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("txt") {
p.file_name().map(|s| s.to_string_lossy().into_owned())
} else {
None
}
})
.collect();
names.sort();
for name in names {
let txt_a = read_or_empty(&ta.join(&name))?;
let txt_b = read_or_empty(&tb.join(&name))?;
if txt_a == txt_b {
continue;
}
println!("\n--- transcripts/{name} (A → B) ---");
print_diff(&txt_a, &txt_b);
}
Ok(())
}
+358
View File
@@ -0,0 +1,358 @@
//! Full-pipeline runner: audio → ffmpeg → whisper → gazetteer → LLM.
//!
//! Reproduces the production pipeline 1:1 by reusing the `doctate-server`
//! crate's modules. Outputs go strictly under `<case-dir>/runs/<run-id>/`.
//!
//! 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`
//! - `--settings` defaults to `../server/settings.toml`
//! - `--vocab-dir` defaults to `../server/vocab`
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::Parser;
use doctate_experiments::{
GazetteerDictMode, RecordingMeta, RunMeta, RunTimings, atomic_write, load_prompt, run_id,
};
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;
use doctate_server::transcribe::whisper as srv_whisper;
use tracing::{info, warn};
#[derive(Parser, Debug)]
#[command(name = "run_full_case")]
struct Args {
/// Directory containing `audios/`, `prompts/`, `runs/` for one case.
#[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<PathBuf>,
/// Path to the LLM system prompt file. Typically under `prompts/llm/`.
#[arg(long)]
llm_prompt: PathBuf,
/// Number of runs to perform with the same variants. 23 recommended
/// to surface model drift.
#[arg(long, default_value_t = 1)]
runs: u32,
/// Settings file (URL, model, API key for LLM; URL, timeout for Whisper).
#[arg(long, default_value = "../server/settings.toml")]
settings: PathBuf,
/// Vocab directory for the gazetteer.
#[arg(long, default_value = "../server/vocab")]
vocab_dir: PathBuf,
/// Hunspell stem (e.g. `/usr/share/hunspell/de_DE`). Optional; if the
/// files don't exist, gazetteer runs without dict-veto.
#[arg(long, default_value = "/usr/share/hunspell/de_DE")]
hunspell_stem: PathBuf,
/// Skip the LLM stage. Useful for measuring pure Whisper-prompt
/// effects without burning Ionos tokens.
#[arg(long, default_value_t = false)]
no_llm: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let args = Args::parse();
let case_dir = args
.case_dir
.canonicalize()
.with_context(|| format!("case-dir not found: {}", args.case_dir.display()))?;
let settings_path = args.settings.to_string_lossy();
let settings = Settings::load_or_default(&settings_path);
if !settings.llm_configured() && !args.no_llm {
anyhow::bail!(
"LLM not configured in {settings_path}. Set [llm].url and [llm].model, or pass --no-llm."
);
}
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(),
loaded = gazetteer.len(),
"gazetteer ready"
);
let audios = collect_audios(&case_dir.join("audios"))?;
if audios.is_empty() {
anyhow::bail!("no .m4a files found under {}/audios", case_dir.display());
}
info!(count = audios.len(), "audios discovered");
let http = reqwest::Client::builder()
.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 run_dir = runs_root.join(&id);
std::fs::create_dir_all(run_dir.join("transcripts"))?;
info!(run = %id, "begin run");
let wallclock = Instant::now();
let mut timings = RunTimings::default();
let mut recordings_meta = Vec::new();
let mut analysis_recordings = Vec::new();
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()))?;
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"));
atomic_write(&transcript_path, &cleaned)?;
recordings_meta.push(RecordingMeta {
recorded_at: recorded_at.clone(),
audio_path: audio.display().to_string(),
whisper_chars_raw: raw.chars().count(),
whisper_chars_after_gazetteer: cleaned.chars().count(),
});
analysis_recordings.push(RecordingInput {
recorded_at,
text: cleaned,
});
}
let last_recording_mtime = audios
.last()
.map(|p| stem(p))
.unwrap_or_else(|| "unknown".to_string());
let analysis_input = AnalysisInput {
last_recording_mtime,
recordings: analysis_recordings,
};
let user_content = render_prompt(&analysis_input);
atomic_write(&run_dir.join("llm_input.txt"), &user_content)?;
if !args.no_llm {
let t_llm = Instant::now();
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)
// that the pre-LLM pass cannot reach because the drift happens
// inside the LLM itself.
let document = gazetteer.replace(&document_raw);
atomic_write(&run_dir.join("document.md"), &document)?;
}
timings.wallclock_ms = wallclock.elapsed().as_millis() as u64;
let meta = RunMeta {
run_id: id.clone(),
created_at_utc: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Iso8601::DEFAULT)
.unwrap_or_default(),
case: case_dir
.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(),
llm_model: settings.llm.model.clone(),
llm_temperature: settings.llm.temperature,
vocab_dir: args.vocab_dir.display().to_string(),
vocab_loaded: gazetteer.len(),
gazetteer_dict: dict_mode_clone(&dict_mode),
timings_ms: timings,
recordings: recordings_meta,
};
let meta_json = serde_json::to_string_pretty(&meta)?;
atomic_write(&run_dir.join("meta.json"), &meta_json)?;
info!(run = %id, wallclock_ms = meta.timings_ms.wallclock_ms, "run done");
}
Ok(())
}
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
}
async fn load_gazetteer(
vocab_dir: &Path,
hunspell_stem: &Path,
) -> Result<(Gazetteer, GazetteerDictMode)> {
let g = Gazetteer::load_dir(vocab_dir)
.await
.with_context(|| format!("loading vocab dir {}", vocab_dir.display()))?;
let aff = hunspell_stem.with_extension("aff");
let dic = hunspell_stem.with_extension("dic");
if aff.exists() && dic.exists() {
match SpellbookDict::load(hunspell_stem) {
Ok(d) => {
let g = g.with_dict(Box::new(d));
Ok((
g,
GazetteerDictMode::SpellbookHunspell {
stem: hunspell_stem.display().to_string(),
},
))
}
Err(e) => {
warn!(
err = %e,
"spellbook dict load failed; gazetteer running without dict-veto"
);
Ok((g, GazetteerDictMode::NoDict))
}
}
} else {
warn!(
stem = %hunspell_stem.display(),
"hunspell .aff/.dic not found; gazetteer running without dict-veto"
);
Ok((g, GazetteerDictMode::NoDict))
}
}
fn dict_mode_clone(m: &GazetteerDictMode) -> GazetteerDictMode {
match m {
GazetteerDictMode::NoDict => GazetteerDictMode::NoDict,
GazetteerDictMode::SpellbookHunspell { stem } => GazetteerDictMode::SpellbookHunspell {
stem: stem.clone(),
},
}
}
/// Resolve the per-case output root under `experiments/_data/<case_name>/runs/`.
/// Inputs (audios/, prompts/, reference/) live under `case_<id>/` and are
/// versioned; outputs go to `_data/` and are gitignored.
fn data_runs_root(case_dir: &Path) -> Result<PathBuf> {
let case_name = case_dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("case-dir has no file name"))?;
let experiments_root = case_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("case-dir has no parent"))?;
let runs = experiments_root.join("_data").join(case_name).join("runs");
std::fs::create_dir_all(&runs)?;
Ok(runs)
}
fn collect_audios(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("m4a") {
out.push(path);
}
}
out.sort();
Ok(out)
}
fn stem<P: AsRef<Path>>(p: P) -> String {
p.as_ref()
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string())
}
async fn transcribe_one(
http: &reqwest::Client,
settings: &Settings,
user_whisper: &WhisperUserSettings,
audio: &Path,
) -> Result<String> {
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}"))?;
Ok(text)
}
async fn call_llm(
http: &reqwest::Client,
settings: &Settings,
system_prompt: &str,
user_content: &str,
) -> Result<String> {
let call = LlmCallSettings {
base_url: &settings.llm.url,
api_key: &settings.llm.api_key,
model: &settings.llm.model,
temperature: settings.llm.temperature,
};
let timeout = Duration::from_secs(settings.llm.timeout_seconds);
let out = srv_llm::chat_once(http, &call, system_prompt, user_content, timeout)
.await
.map_err(|e| anyhow::anyhow!("llm chat failed: {e}"))?;
Ok(out)
}
+224
View File
@@ -0,0 +1,224 @@
//! LLM-only iteration: skip Whisper, use the production-bereinigten
//! transcripts that already live next to the audio files (`<stem>.json`
//! contains the gazetteer-cleaned text the server uses as LLM input).
//!
//! Usage:
//! run_llm_only \
//! --case-dir case_c414cf52 \
//! --transcripts-dir /home/brummel/dev/doctate/tmpdata/dr_mueller/c414cf52-1ca8-440d-844f-a7d19ed584fd \
//! --llm-prompt prompts/llm/v1_keep_headings.txt \
//! --runs 3
use std::path::{Path, PathBuf};
fn data_runs_root(case_dir: &Path) -> anyhow::Result<PathBuf> {
let case_name = case_dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("case-dir has no file name"))?;
let experiments_root = case_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("case-dir has no parent"))?;
let runs = experiments_root.join("_data").join(case_name).join("runs");
std::fs::create_dir_all(&runs)?;
Ok(runs)
}
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::Parser;
use doctate_experiments::{
GazetteerDictMode, RecordingMeta, RunMeta, RunTimings, atomic_write, load_prompt, run_id,
};
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::gazetteer::Gazetteer;
use doctate_server::settings::Settings;
use serde::Deserialize;
use tracing::info;
#[derive(Parser, Debug)]
#[command(name = "run_llm_only")]
struct Args {
#[arg(long)]
case_dir: PathBuf,
/// Directory containing the `<recorded_at>.json` transcript files.
/// Typically `tmpdata/<user>/<case-uuid>/`.
#[arg(long)]
transcripts_dir: PathBuf,
#[arg(long)]
llm_prompt: PathBuf,
#[arg(long, default_value_t = 1)]
runs: u32,
#[arg(long, default_value = "../server/settings.toml")]
settings: PathBuf,
/// Vocab dir for the Post-LLM gazetteer pass. Mirrors the server's
/// `analyze/worker.rs:150` post-AI normalization. Pass `none` to skip.
#[arg(long, default_value = "../server/vocab")]
vocab_dir: PathBuf,
}
#[derive(Debug, Deserialize)]
struct StoredTranscript {
transcript: TranscriptInner,
}
#[derive(Debug, Deserialize)]
struct TranscriptInner {
text: String,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let args = Args::parse();
let case_dir = args
.case_dir
.canonicalize()
.with_context(|| format!("case-dir not found: {}", args.case_dir.display()))?;
let settings = Settings::load_or_default(&args.settings.to_string_lossy());
if !settings.llm_configured() {
anyhow::bail!("LLM not configured in {}", args.settings.display());
}
let llm_system_prompt = load_prompt(&args.llm_prompt)?;
let recordings = load_transcripts(&args.transcripts_dir)?;
info!(count = recordings.len(), "transcripts loaded");
let analysis_input = AnalysisInput {
last_recording_mtime: recordings
.last()
.map(|r| r.recorded_at.clone())
.unwrap_or_default(),
recordings,
};
let user_content = render_prompt(&analysis_input);
let http = reqwest::Client::builder().build()?;
let llm_variant_id = stem(&args.llm_prompt);
let runs_root = data_runs_root(&case_dir)?;
let gazetteer = Gazetteer::load_dir(&args.vocab_dir)
.await
.with_context(|| format!("loading vocab dir {}", args.vocab_dir.display()))?;
info!(loaded = gazetteer.len(), vocab_dir = %args.vocab_dir.display(), "post-llm gazetteer ready");
for run_index in 1..=args.runs {
let id = run_id("none", &llm_variant_id, run_index);
let run_dir = runs_root.join(&id);
std::fs::create_dir_all(&run_dir)?;
atomic_write(&run_dir.join("llm_input.txt"), &user_content)?;
let wallclock = Instant::now();
let t_llm = Instant::now();
let document_raw = call_llm(&http, &settings, &llm_system_prompt, &user_content).await?;
// Post-LLM gazetteer pass — mirrors server/src/analyze/worker.rs:150
let document = gazetteer.replace(&document_raw);
let timings = RunTimings {
whisper_total_ms: 0,
gazetteer_total_ms: 0,
llm_ms: t_llm.elapsed().as_millis() as u64,
wallclock_ms: wallclock.elapsed().as_millis() as u64,
};
atomic_write(&run_dir.join("document.md"), &document)?;
let meta = RunMeta {
run_id: id.clone(),
created_at_utc: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Iso8601::DEFAULT)
.unwrap_or_default(),
case: case_dir
.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(),
llm_model: settings.llm.model.clone(),
llm_temperature: settings.llm.temperature,
vocab_dir: format!("{} (post-LLM only)", args.vocab_dir.display()),
vocab_loaded: gazetteer.len(),
gazetteer_dict: GazetteerDictMode::NoDict,
timings_ms: timings,
recordings: Vec::<RecordingMeta>::new(),
};
atomic_write(
&run_dir.join("meta.json"),
&serde_json::to_string_pretty(&meta)?,
)?;
info!(run = %id, "llm-only run done");
}
Ok(())
}
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
}
fn load_transcripts(dir: &Path) -> Result<Vec<RecordingInput>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let recorded_at = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if recorded_at.is_empty() || recorded_at == "oneliner" {
continue;
}
let raw = std::fs::read_to_string(&path)?;
let parsed: StoredTranscript = match serde_json::from_str(&raw) {
Ok(p) => p,
Err(_) => continue,
};
out.push(RecordingInput {
recorded_at,
text: parsed.transcript.text,
});
}
out.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at));
Ok(out)
}
fn stem<P: AsRef<Path>>(p: P) -> String {
p.as_ref()
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string())
}
async fn call_llm(
http: &reqwest::Client,
settings: &Settings,
system_prompt: &str,
user_content: &str,
) -> Result<String> {
let call = LlmCallSettings {
base_url: &settings.llm.url,
api_key: &settings.llm.api_key,
model: &settings.llm.model,
temperature: settings.llm.temperature,
};
let timeout = Duration::from_secs(settings.llm.timeout_seconds);
srv_llm::chat_once(http, &call, system_prompt, user_content, timeout)
.await
.map_err(|e| anyhow::anyhow!("llm chat failed: {e}"))
}
+98
View File
@@ -0,0 +1,98 @@
//! Helpers shared by the experiment binaries.
//!
//! Sandbox tooling for iterating on Whisper / LLM prompts against a real case
//! without modifying production state. All outputs land under `case_<id>/runs/`
//! and `case_<id>/compare/` — never in `tmpdata/`.
//!
//! **Security note**: The Ionos bearer key flows through this code as a
//! `String` inside `Settings.llm.api_key`. Helpers here MUST NOT persist or
//! print the key. `RunMeta` deliberately stores only the URL/model/timestamp —
//! never the key — so on-disk audit logs are safe to share.
use std::path::Path;
use anyhow::Context;
use serde::Serialize;
use time::OffsetDateTime;
use time::format_description::well_known::Iso8601;
/// Generate a run identifier in the form
/// `<UTC-ISO>_w-<wid>_l-<lid>_run<n>`. Used as the directory name under
/// `case_<id>/runs/`.
pub fn run_id(whisper_variant: &str, llm_variant: &str, run_index: u32) -> String {
let now = OffsetDateTime::now_utc();
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}")
}
/// Read a prompt file. Returns empty string for an empty file (used as
/// "no prompt" signal by `run_full_case`'s settings override).
pub fn load_prompt(path: &Path) -> anyhow::Result<String> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading prompt file {}", path.display()))?;
Ok(content.trim_end_matches('\n').to_string())
}
/// Atomically write content to `path` via a sibling tempfile + rename.
/// Same pattern the server uses for `document.md` — ensures readers never
/// observe a partial file.
pub fn atomic_write(path: &Path, content: &str) -> anyhow::Result<()> {
let parent = path
.parent()
.with_context(|| format!("path {} has no parent", path.display()))?;
std::fs::create_dir_all(parent)?;
let mut tmp = tempfile::Builder::new()
.prefix(".tmp-")
.tempfile_in(parent)?;
use std::io::Write;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("persist failed: {}", e.error))?;
Ok(())
}
/// Snapshot of variant identities + provenance for one run. Serialized
/// as `meta.json` next to the produced `document.md`. **Never contains
/// the API key** — only the LLM endpoint URL and model name.
#[derive(Debug, Serialize)]
pub struct RunMeta {
pub run_id: String,
pub created_at_utc: String,
pub case: String,
pub whisper_variant: String,
pub whisper_hotwords_variant: Option<String>,
pub llm_variant: String,
pub whisper_url: String,
pub llm_url: String,
pub llm_model: String,
pub llm_temperature: f32,
pub vocab_dir: String,
pub vocab_loaded: usize,
pub gazetteer_dict: GazetteerDictMode,
pub timings_ms: RunTimings,
pub recordings: Vec<RecordingMeta>,
}
#[derive(Debug, Serialize)]
pub enum GazetteerDictMode {
NoDict,
SpellbookHunspell { stem: String },
}
#[derive(Debug, Default, Serialize)]
pub struct RunTimings {
pub whisper_total_ms: u64,
pub gazetteer_total_ms: u64,
pub llm_ms: u64,
pub wallclock_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct RecordingMeta {
pub recorded_at: String,
pub audio_path: String,
pub whisper_chars_raw: usize,
pub whisper_chars_after_gazetteer: usize,
}