From a6aeae77a12d7e832cdaacdce8be6168ee43fd10 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 14 Apr 2026 12:07:18 +0200 Subject: [PATCH] Update project plan regarding hotwords functionality --- docs/projektplan.md | 4 +- scripts/regress_whisper.sh | 109 +++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100755 scripts/regress_whisper.sh diff --git a/docs/projektplan.md b/docs/projektplan.md index 3a9c867..8d54596 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -725,7 +725,7 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 - [ ] Ollama Health-Check + Retry - [ ] Lazy Cleanup bei Falllisten-Zugriff (zum Entfernen markierte Fälle vom Vortag löschen) - [ ] Retention-Prüfung bei Zugriff (Audio/Transkripte nach RETENTION_*_DAYS) -- [x] Per-User Whisper-Settings in `users.toml` (`[user.whisper]` mit `language`, `hotwords`, `initial_prompt`) — Worker reicht sie pro Upload an den `whisper/`-Service durch +- [x] Per-User Whisper-Settings in `users.toml` (`[user.whisper]` mit `language`, `hotwords`, `initial_prompt`) — Worker reicht sie pro Upload an den `whisper/`-Service durch. **Hotwords sind technisch verfügbar, werden aber nicht als Feature angeboten** (siehe Abweichungen). ### Phase 2b.5 — Eigener faster-whisper-Service (`whisper/`) - [x] FastAPI-Wrapper um `faster-whisper` mit fest verdrahteten Anti-Halluzinations-Params (`condition_on_previous_text=False`, `temperature=0.0`, `vad_filter=True`) @@ -881,4 +881,4 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 | faster-whisper Port | 10300 (ahmetoner) | 9001 (eigener Service) | Neuer Service auf freiem Port; alter Container optional parallel belassen. | | Test-Client für Watch-Flow | Erst ab Phase 5 mit echter Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + interaktiver c/n/r/q-Loop) | Erlaubt End-to-End-Tests der Server-Pipeline ohne Watch-Hardware, solange die Pixel Watch nicht verfügbar ist. | | Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `/web/` (flache Liste aller Fälle, Transkripte, Oneliner) | Gebaut, bevor das Arzt-UI (Session, States, Fall-Detail) existiert, um die Pipeline während Entwicklung inspizieren zu können. Soll bleiben, aber später hinter `role = "admin"` geschützt; das Arzt-UI wird separat entwickelt. | -| Hotwords | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` in `users.toml` | Experiment zeigte: Hotwords sind der einzige Hebel, der Whisper-Fachvokabular zuverlässig verbessert (8/8 vs. 3/8 ohne). Pro-User statt global, weil Kardiologie/Orthopädie/Psychiatrie völlig andere Begriffslisten brauchen. Gleiches Schema lässt sich später auf `[user.oneliner]` erweitern. | +| Hotwords | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` bleibt **im Code**, wird aber **nicht als Feature angeboten** | Erstes Ad-hoc-Experiment sah 8/8 vs. 3/8 aus. Regress-Lauf über 10 kuratierte Fixtures (`scripts/regress_whisper.sh`) widerlegte das: ohne Hotwords 12/257 Wortfehler (~4.7% WER), mit Hotwords 14/257 — leicht schlechter. Hotwords schluckten sogar Funktionswörter wie „Beginn mit" und „mittels". Kein belegter Nutzen, dafür zusätzliche Bedienkomplexität pro Arzt. KISS: wir lassen die Leitung durchverdrahtet (kein Code-Rückbau), aber bewerben/konfigurieren es nicht. Re-evaluiert, sobald ein konkreter Fachvokabular-Bedarf auftaucht. | diff --git a/scripts/regress_whisper.sh b/scripts/regress_whisper.sh new file mode 100755 index 0000000..4ea0218 --- /dev/null +++ b/scripts/regress_whisper.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Regression test for the whisper service: runs every fixture under +# tests/fixtures/dictations/ through /asr twice (with and without hotwords) +# and reports word error rate vs the golden `.expected.txt`. +# +# Usage: +# ./scripts/regress_whisper.sh +# WHISPER_URL=http://minerva.lan:9001 ./scripts/regress_whisper.sh +# ./scripts/regress_whisper.sh --verbose # also print each hypothesis +# +# Exit code is the sum of mismatches across all hotwords runs (0 = perfect). +set -euo pipefail + +WHISPER_URL="${WHISPER_URL:-http://minerva.lan:9001}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIR="$REPO_ROOT/tests/fixtures/dictations" +VERBOSE=0 +[[ "${1:-}" == "--verbose" || "${1:-}" == "-v" ]] && VERBOSE=1 + +die() { echo "error: $*" >&2; exit 1; } +[ -d "$DIR" ] || die "fixtures dir missing: $DIR" +command -v python3 >/dev/null || die "python3 not found" + +# POST a single fixture. If hotwords arg is non-empty, forward it as a form +# field so the whisper service can feed it into faster_whisper. +run_once() { + local m4a="$1" hotwords="$2" + if [ -n "$hotwords" ]; then + curl -sS --max-time 120 -X POST "$WHISPER_URL/asr?output=txt&language=de" \ + -F "audio_file=@$m4a" \ + -F "hotwords=$hotwords" + else + curl -sS --max-time 120 -X POST "$WHISPER_URL/asr?output=txt&language=de" \ + -F "audio_file=@$m4a" + fi +} + +# Word error rate via Levenshtein. Prints "errors/ref_len". +wer() { + python3 - "$1" "$2" <<'PY' +import re, sys, unicodedata + +def norm(path): + with open(path, encoding="utf-8") as f: + s = f.read() + s = unicodedata.normalize("NFC", s).lower() + s = re.sub(r"[^\w\s]", " ", s, flags=re.UNICODE) + return s.split() + +def lev(a, b): + m, n = len(a), len(b) + if m == 0 or n == 0: return max(m, n) + prev = list(range(n + 1)) + for i, ca in enumerate(a, 1): + cur = [i] + [0] * n + for j, cb in enumerate(b, 1): + cost = 0 if ca == cb else 1 + cur[j] = min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost) + prev = cur + return prev[n] + +ref, hyp = norm(sys.argv[1]), norm(sys.argv[2]) +print(f"{lev(ref, hyp)}/{len(ref)}") +PY +} + +printf "%-38s %-13s %-13s\n" "fixture" "no_hotwords" "with_hotwords" +printf "%-38s %-13s %-13s\n" "--------------------------------------" "-------------" "-------------" + +total_hw_errs=0 +for m4a in "$DIR"/*.m4a; do + name="$(basename "$m4a" .m4a)" + exp="$DIR/$name.expected.txt" + hw_file="$DIR/$name.hotwords.txt" + [ -f "$exp" ] || { echo "skip $name (no .expected.txt)"; continue; } + + tmp_no="$(mktemp)" + tmp_hw="$(mktemp)" + trap 'rm -f "$tmp_no" "$tmp_hw"' EXIT + + run_once "$m4a" "" > "$tmp_no" + wer_no="$(wer "$exp" "$tmp_no")" + + if [ -f "$hw_file" ]; then + hw="$(tr -d '\n' < "$hw_file")" + run_once "$m4a" "$hw" > "$tmp_hw" + wer_hw="$(wer "$exp" "$tmp_hw")" + total_hw_errs=$(( total_hw_errs + ${wer_hw%/*} )) + else + wer_hw="—" + total_hw_errs=$(( total_hw_errs + ${wer_no%/*} )) + fi + + printf "%-38s %-13s %-13s\n" "$name" "$wer_no" "$wer_hw" + + if [ "$VERBOSE" -eq 1 ]; then + echo " expected: $(tr -d '\n' < "$exp")" + echo " no_hotwords: $(tr -d '\n' < "$tmp_no")" + if [ -f "$hw_file" ]; then + echo " with_hotwords: $(tr -d '\n' < "$tmp_hw")" + fi + fi + + rm -f "$tmp_no" "$tmp_hw" +done + +echo +echo "Total hotwords-run word errors: $total_hw_errs" +exit "$total_hw_errs"