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:
@@ -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_*/
|
||||||
Generated
+2732
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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: 3–4 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]").
|
||||||
@@ -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: 3–4 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]").
|
||||||
@@ -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: 3–4 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.
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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. 2–3 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)
|
||||||
|
}
|
||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user