Refactor transcript file handling to use JSON metadata

This commit changes the way transcriptions are stored and accessed.
Instead of using plain text files (`.transcript.txt`), transcriptions
will now be part of a JSON metadata file (`<stem>.json`). This allows
for richer metadata to be stored alongside the transcript, such as
duration, and provides a more robust mechanism for tracking
transcription states.

The changes include:
- Updating documentation and code to reflect the new `.json` file
  extension.
- Modifying file handling logic to read and write JSON metadata.
- Adjusting tests to accommodate the new file format.
This commit is contained in:
2026-04-27 13:08:36 +02:00
parent 66b3b7e4c8
commit c15590f3e0
10 changed files with 78 additions and 46 deletions
+12 -13
View File
@@ -73,7 +73,7 @@ Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einz
> **Alle Clients sprechen ausschließlich die einheitliche Server-HTTP-Schnittstelle.** Es gibt keine gerätespezifische API und keinen geräteeigenen Backchannel. Die Rolle eines Clients ergibt sich allein daraus, *welchen Teil* der API er nutzt.
>
> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`.transcript.txt`) und die daraus generierten Dokumente (`document.md`) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit.
> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`<stem>.json`) und die daraus generierten Dokumente (`document.md`) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit.
>
> **Informelle Navigationsdaten dürfen lokal gecacht werden.** Informell sind Fall-IDs, Zeitstempel, Fall-Listen und der Oneliner — kurze Orientierungsdaten, die dem Arzt zeigen, *welche* Fälle existieren, ohne deren medizinischen Inhalt preiszugeben. Der Server bleibt auch hier die autoritative Quelle; der Client-Cache ist verwerfbar und wird beim nächsten erfolgreichen Poll überschrieben. Beispiel: `doctate-client-core::snapshot_cache` persistiert die Oneliner-/Fall-Liste, damit der Desktop-Client beim Launch keinen Flash-Fehlzustand zeigt.
@@ -478,7 +478,7 @@ Trifft ein Upload für einen Fall mit `.deleted`-Marker ein, wird der Marker ent
```
Whisper-Output ──┐
Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (.transcript.txt, oneliner.json, document.md)
Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (`<stem>.json`, oneliner.json, document.md)
Analyse-LLM ──────┘
```
@@ -508,7 +508,7 @@ Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, be
| Worker | Eingang | Externer Call | Ausgang |
|---|---|---|---|
| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.transcript.txt`, anschließend Oneliner via Ollama |
| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.json` (single atomic write: transcript + duration), anschließend Oneliner via Ollama |
| `analyze::worker` | `AnalyzeSender` (unbounded) | `LLM_URL/v1/chat/completions` | `document.md` |
Jeder Worker arbeitet sequentiell (ein Job nach dem anderen). Parallelität innerhalb eines Workers ist bewusst ausgeschlossen — die GPU auf der Whisper-Seite kann nur eine Aufgabe gleichzeitig sinnvoll bedienen, und der Analyse-LLM profitiert nicht von Burst-Lasten. Beide Worker laufen aber **zueinander parallel**: während Whisper noch transkribiert, kann der Analyse-Worker bereits einen anderen Fall abschließen.
@@ -518,7 +518,7 @@ Jeder Worker arbeitet sequentiell (ein Job nach dem anderen). Parallelität inne
**Live-Flag pro Worker:** `WorkerBusy = Arc<AtomicBool>` + `BusyGuard` (RAII). Der Worker setzt `true` bei Job-Start, `false` bei Job-Ende (Drop-safe). Das UI nutzt das Flag, um zwischen echter In-flight-Aufgabe und orphaned On-disk-Markern (Crash-Residuen) zu unterscheiden. Es ersetzt den ursprünglich geplanten `RwLock<HashMap<CaseId, CaseState>>` — aufgrund der sequentiellen Worker-Semantik genügt ein einfaches Flag.
**Concurrency-Schutz aktuell:**
- Upload-Handler und Worker teilen sich keinen In-Memory-State; Synchronisation läuft ausschließlich über das Dateisystem (z.B. `has_pending_recordings(case_dir)` scannt nach `.m4a` ohne passendes `.transcript.txt`).
- Upload-Handler und Worker teilen sich keinen In-Memory-State; Synchronisation läuft ausschließlich über das Dateisystem (z.B. `has_pending_recordings(case_dir)` scannt nach `.m4a` ohne passendes `<stem>.json`).
- Race zwischen Upload-Write und Worker-Scan: in der Praxis unkritisch, weil nachfolgende Uploads erneut in die Queue wandern und ein weiterer Recovery-Scan offene Stellen findet. Im Plan als Phase-3-TODO markiert, bei Bedarf auf explizites Locking nachrüstbar.
**Latenz-Profil (3 Ärzte, ~10 Min. pro Patient, Turbo-Modell):**
@@ -553,7 +553,7 @@ Silent-Case-Handling: bei `Error` bleibt ein evtl. bereits existierender `Ready`
Der Arzt kann den Oneliner weiterhin durch eine explizite Bezeichnung im Diktat beeinflussen („Bezeichnung: Kniegelenk"). Die Erkennung läuft vollständig über den Ollama-Prompt, keine deterministische Keyword-Suche.
**Queue-Recovery bei Serverstart:**
- `transcribe::recovery::scan_and_enqueue`: findet alle `.m4a` ohne passendes `.transcript.txt` (und ohne `.m4a.failed`) und schiebt sie in die Transcribe-Queue.
- `transcribe::recovery::scan_and_enqueue`: findet alle `.m4a` ohne passendes `<stem>.json` (und ohne `.m4a.failed`) und schiebt sie in die Transcribe-Queue.
- `transcribe::recovery::regenerate_missing_oneliners`: für Fälle mit Transkripten aber ohne `oneliner.json` (oder mit `OnelinerState::Error`) wird der Oneliner einmalig erzeugt. `Empty` gilt als Endzustand und wird **nicht** retryed.
- `analyze::recovery::scan_and_enqueue`: findet `analysis_input.json` ohne `document.md` und reiht sie in die Analyze-Queue ein.
- Kein Datenverlust bei Server-Neustart; alle drei Scans laufen bei Boot parallel.
@@ -744,8 +744,7 @@ Fall 09:32 — 3 Aufnahmen
└── {case_id}/
├── {UTC-timestamp}.m4a ← Aufnahme (unveränderlich)
├── {UTC-timestamp}.m4a.failed ← optional: dauerhaft gescheiterte Aufnahme
├── {UTC-timestamp}.duration.txt ← ffprobe-Dauer in Sekunden (UI-Rendering; lazy-backfill)
├── {UTC-timestamp}.transcript.txt ← nach erfolgreicher Transkription (Gazetteer-normalisiert)
├── {UTC-timestamp}.json ← `RecordingMeta` (Transcript + duration_seconds), single atomic write am Ende des Worker-Pipelines
├── oneliner.json ← `OnelinerState` (Ready/Empty/Error), aus allen Transkripten regeneriert
├── analysis_input.json ← nur während Analyse-Lauf (wird nach Erfolg gelöscht)
├── .analysis_failed.json ← Auto-Trigger-Retry-Gate (JSON: last_recording_mtime, reason, failed_at)
@@ -771,9 +770,9 @@ Fall 09:32 — 3 Aufnahmen
| Datei | Bedeutung |
|---|---|
| `{ts}.m4a` ohne `{ts}.transcript.txt` | Transkriptions-Auftrag offen |
| `{ts}.m4a` ohne `{ts}.json` | Transkriptions-Auftrag offen |
| `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" |
| `{ts}.duration.txt` | ffprobe-Dauer in ganzen Sekunden (Sidecar für HTML5-Audio-Player) |
| `{ts}.json` | `RecordingMeta` mit `transcript` (`Silent` oder `Content`) und `duration_seconds`. Single atomic write — Existenz = transcribiert |
| `oneliner.json` | `OnelinerState` (Ready/Empty/Error). Fehlt oder `Error` → Recovery-Retry; `Empty`/`Ready` sind Endzustände |
| `analysis_input.json` vorhanden | Analyse-Job in Queue / in-flight |
| `.analysis_failed.json` vorhanden | Auto-Trigger-Retry-Gate; Auto-Analyse übersprungen, solange `last_recording_mtime` unverändert |
@@ -1157,13 +1156,13 @@ wiremock = "0.6"
### Phase 3 — Fallverwaltung
- [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (`compute_flags` in `user_web.rs`, kein State-Sidecar)
- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `.transcript.txt` haben
- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `<stem>.json` haben
- [x] "Analysieren" nur möglich, wenn LLM konfiguriert und Fall transkribiert — `handle_analyze_case` in `routes/case_actions.rs`
- [x] Aufnahmen nach case_id zusammenführen — `analysis_input.json` (single, nicht versioniert) enthält alle Transkripte
- [x] Chronologische Sortierung nach UTC-Zeitstempel — lexikographische Filename-Sortierung
- [x] Externes LLM → Dokument generieren — `analyze/`-Modul, OpenAI-kompatibel (`llm.rs`)
- [x] Fall soft-löschen — `.deleted`-Marker (JSON mit Batch-UUID), Undo letzter Batch via `POST /web/cases/undo-delete`
- [x] Einzel-Aufnahme hart löschen — `POST /web/cases/{case_id}/recordings/delete` entfernt `.m4a` + `.transcript.txt` + `.duration.txt`, invalidiert `oneliner.json`/`document.md`/`analysis_input.json`; Auto-Trigger regeneriert beim nächsten View-Load (siehe Phase 4)
- [x] Einzel-Aufnahme hart löschen — `POST /web/cases/{case_id}/recordings/delete` entfernt `.m4a` + `<stem>.json`, invalidiert `oneliner.json`/`document.md`/`analysis_input.json`; Auto-Trigger regeneriert beim nächsten View-Load (siehe Phase 4)
- [x] Bulk-Aktionen (analyze/delete auf mehrere Fälle gleichzeitig) — `POST /web/cases/bulk`
- [x] Reset-Endpoint — löscht Transkripte/Analyse/Document, re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`)
- [x] Upload verspätet für gelöschten Fall → `.deleted`-Marker entfernen, Nachtrag normal behandeln
@@ -1186,7 +1185,7 @@ wiremock = "0.6"
- [x] Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch
- [x] Fall-Übersicht: `GET /web/cases/{case_id}` rendert `case_page.html` mit Oneliner + Aktionen + inline-gerendertem Dokument (via `pulldown-cmark`); IDOR-geschützt via Session-Slug.
- [x] Einzel-Transkripte: `GET /web/cases/{case_id}/recordings` rendert `case_recordings.html` (ein Eintrag pro Aufnahme, Audio-Link pro Transkript).
- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration-Sidecar `{ts}.duration.txt` für HTML5-Player mit Seeking
- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration aus `{ts}.json` (`duration_seconds`-Feld) für HTML5-Player mit Seeking
- [ ] Replay-Gain-Normalisierung für Wiedergabe (nicht destruktiv) — verschiedene Erfassungsgeräte liefern stark unterschiedliche Pegel (Watch `MIC` ~-38 dB mean, Desktop ~-27 dB mean). PoC am 2026-04-23 erfolgreich, aber nicht committed; Re-Implementierung steht aus. Erprobte Architektur: pro `.m4a` ein `<stem>.loudness.json`-Sidecar mit statischem `gain_db` (aus `ffmpeg -af volumedetect`, Ziel 16 dB mean, Peak-Cap 1 dB). Lazy-Backfill im bestehenden `scan_recordings`-JoinSet analog zum Duration-Sidecar. Browser appliziert den Gain über Web Audio API `GainNode` (kein Disk-Rewrite, Original + Whisper unberührt). Kombiniert mit `AudioSource.VOICE_RECOGNITION` auf der Watch (besseres SNR, siehe 5b). Verworfene Alternativen: `ffmpeg loudnorm` (pumpt + Artefakte), fixes `volume=+XdB` (client-abhängig). Kern-Einsicht: SNR > Loudness an der Source, solange Post-Gain verfügbar ist.
- [x] Fall analysieren — Button in der Fall-Übersicht
- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk`**admin-only** (`AuthenticatedUser::is_admin()` auf `role == "admin"`, Check am Entry-Handler).
@@ -1397,7 +1396,7 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
| Bulk-Aktionen | Nur „Alle abschließen" im UI angedacht | `POST /web/cases/bulk` mit mehreren markierten Fällen, Aktion `analyze` oder `delete` | Realer Workflow: Arzt räumt am Tagesende mehrere Fälle gleichzeitig ab. |
| Reset-Endpoint | Nicht vorgesehen | `POST /web/cases/{id}/reset` löscht Transkripte/Oneliner/Analyse/Document und re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`) | Debug-Tool während der Entwicklung; hilft bei Prompt-Iteration und Gazetteer-Tuning, ohne den Case neu aufzunehmen. |
| Audio-Streaming-Route | Nicht vorgesehen | `GET /web/audio/{user}/{case_id}/{filename}` mit Cookie-Auth (Arzt eigene Audios, Admin alle) | Ermöglicht das direkte Anhören im Browser — unverzichtbar für Plausibilitätsprüfung bei Gazetteer/LLM-Fehlern. |
| Audio-Seeking | Nicht vorgesehen | HTTP-Range-Requests in `handle_audio` (`parse_range` + `serve_range`), `Accept-Ranges: bytes`, 206 Partial Content; Duration-Sidecar `{ts}.duration.txt` (ffprobe auf der remuxten Kopie, ~ms) für Player-Rendering ohne HEAD-Roundtrips | HTML5-`<audio>`-Player brauchen Range für Seek ohne Re-Download. Sidecar spart den Extra-HEAD pro Transkript-Zeile; Worker schreibt ihn best-effort, lazy-backfill vorgesehen. |
| Audio-Seeking | Nicht vorgesehen | HTTP-Range-Requests in `handle_audio` (`parse_range` + `serve_range`), `Accept-Ranges: bytes`, 206 Partial Content; Duration aus `{ts}.json` (`duration_seconds`-Feld, ffprobe auf der remuxten Kopie, ~ms) für Player-Rendering ohne HEAD-Roundtrips | HTML5-`<audio>`-Player brauchen Range für Seek ohne Re-Download. Single-write der Recording-Metadaten (transcript + duration in einem atomaren JSON) spart den Extra-HEAD pro Zeile; während des Whisper-Fensters füllt ein read-only `ffprobe`-Backfill in `scan_recordings` die Lücke. |
| Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. |
| Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. |
| Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. |
+4 -4
View File
@@ -319,10 +319,10 @@ mod tests {
}
/// Seed a `<stem>.json` next to a (presumed already-touched)
/// `<stem>.m4a`. Variant is `Transcript::Silent` because these
/// tests historically used an empty `.transcript.txt`, which
/// mapped to `TranscriptState::Silent`. Tests that need real
/// content call `seed_meta_with` with the desired variant.
/// `<stem>.m4a`. Variant is `Transcript::Silent` — these tests
/// only care that the recording is in *some* terminal state
/// (transcribed), not what was actually said. Tests that need
/// real content call `seed_meta_with` with the desired variant.
async fn seed_meta(case_dir: &std::path::Path, stem: &str) {
crate::paths::write_recording_meta_sync(
case_dir,
+3 -4
View File
@@ -176,10 +176,9 @@ pub async fn delete_oneliner_unless_manual(
/// to it with the `.json` extension.
///
/// This is the single reader of that sidecar's transcript field.
/// Every call-site that used to do
/// `tokio::fs::read_to_string(...).ok()` or
/// `with_extension("transcript.txt").exists()` should go through here
/// so the three-state semantics stay consistent across the codebase.
/// Every "is this recording transcribed?" check should go through
/// here so the three-state semantics (Pending / Silent / Content)
/// stay consistent across the codebase.
pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
TranscriptState::from_meta(read_recording_meta(audio_stem_path).await.as_ref())
}
+32 -4
View File
@@ -492,20 +492,48 @@ async fn replace_real_case_dry() {
.expect("load vocab");
println!("Gazetteer entries: {}", vocab.len());
// Pair the recording metadata sidecars (`<stem>.json`) with their
// audio file so case-level JSONs (oneliner.json, analysis_input.json)
// are skipped automatically.
let mut names: Vec<_> = std::fs::read_dir(&case_dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
let Some(stem) = p
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.ends_with(".transcript.txt"))
.unwrap_or(false)
.and_then(|s| s.strip_suffix(".json"))
else {
return false;
};
// Per-recording sidecar iff a sibling `<stem>.m4a` exists.
p.with_file_name(format!("{stem}.m4a")).exists()
})
.collect();
names.sort();
for path in names {
let text = std::fs::read_to_string(&path).unwrap();
let bytes = std::fs::read(&path).unwrap();
let meta: doctate_common::RecordingMeta = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
println!(
"\n=== {} === [malformed: {e}]",
path.file_name().unwrap().to_string_lossy()
);
continue;
}
};
let text = match meta.transcript {
doctate_common::Transcript::Content { text } => text,
doctate_common::Transcript::Silent => {
println!(
"\n=== {} === [silent — no text]",
path.file_name().unwrap().to_string_lossy()
);
continue;
}
};
let replaced = vocab.replace(&text);
println!("\n=== {} ===", path.file_name().unwrap().to_string_lossy());
println!("--- original ---\n{text}");
+2 -3
View File
@@ -31,7 +31,7 @@ pub fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
/// `transcript`:
/// - `None` → no sidecar written (recording stays in `Pending` state).
/// - `Some(text)` with whitespace-only `text` → sidecar with
/// `Transcript::Silent` (matches the old `transcript.txt = ""` rule).
/// `Transcript::Silent` (mirrors the worker's classification rule).
/// - `Some(text)` non-empty → sidecar with `Transcript::Content`.
///
/// Returns the audio filename (no path) so the caller can feed it back
@@ -79,8 +79,7 @@ pub fn seed_recording_meta(
/// Map a free-text transcript to the `Transcript` variant the worker
/// would have produced for it: whitespace-only → `Silent`, otherwise
/// `Content`. Mirrors the old `transcript.txt = "" ⇒ Silent` rule so
/// existing tests keep their meaning.
/// `Content`. Matches `worker::run`'s post-Whisper classification.
fn transcript_for(text: &str) -> Transcript {
if text.trim().is_empty() {
Transcript::Silent
+6 -1
View File
@@ -382,7 +382,12 @@ async fn case_recordings_page_renders_csrf_token_hidden_field() {
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.transcript.txt"), b"hi").unwrap();
common::seed_recording_meta(
&case_dir,
"2026-04-14T10-00-00Z",
common::Transcript::Content { text: "hi".into() },
None,
);
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
@@ -5,14 +5,14 @@
//! `compute_oneliner_display` fallback.
//!
//! The historical bug path: `.m4a.failed`-only cases were skipped by the
//! recovery scan (`has_any_transcript` looked for `*.transcript.txt`
//! only), so `update_oneliner` never ran, so no `oneliner.json` was
//! written. The UI masked the symptom with its `non_failed_count == 0`
//! fallback — but a future refactor of that fallback would regress the
//! case. The fix makes `has_any_transcript` count `.m4a.failed` too, so
//! `update_oneliner` runs, sees no content transcripts, and settles the
//! case to `Empty` through the same terminal branch used for silent-only
//! cases.
//! recovery scan because `has_any_transcript` only looked for transcript
//! sidecars and ignored failure markers. `update_oneliner` therefore
//! never ran, so no `oneliner.json` was written. The UI masked the
//! symptom with its `non_failed_count == 0` fallback — but a future
//! refactor of that fallback would regress the case. The fix makes
//! `has_any_transcript` count `.m4a.failed` too, so `update_oneliner`
//! runs, sees no content transcripts, and settles the case to `Empty`
//! through the same terminal branch used for silent-only cases.
mod common;
+3 -2
View File
@@ -61,8 +61,9 @@ async fn reset_case_preserves_manual_oneliner_override() {
&format!("/web/cases/{case_id}")
);
// Transcript was wiped (the rest of the reset semantics still works).
assert!(!dir.join("2026-04-26T10-00-00Z.transcript.txt").exists());
// Recording metadata sidecar was wiped (the rest of the reset
// semantics still works).
assert!(!dir.join("2026-04-26T10-00-00Z.json").exists());
// …but the Manual override is untouched.
let bytes = std::fs::read(dir.join(ONELINER_FILENAME))
.expect("oneliner.json must still exist after reset");
+5 -5
View File
@@ -2,11 +2,11 @@
//! (whisper classified as silence) must produce an `OnelinerState::Empty`
//! automatically, so the UI doesn't stick on "Generating" forever.
//!
//! The historical bug path: whisper wrote a 0-byte `.transcript.txt`,
//! `update_oneliner` saw no content and returned early without persisting
//! any state, and the UI's `compute_oneliner_display` collapsed the
//! Silent file onto "transcribed → Generating" (missing state). The fix
//! makes `update_oneliner` settle the case to `OnelinerState::Empty`
//! The historical bug path: whisper wrote a sidecar marking the recording
//! as silent, `update_oneliner` saw no content and returned early without
//! persisting any state, and the UI's `compute_oneliner_display` collapsed
//! the Silent state onto "transcribed → Generating" (missing state). The
//! fix makes `update_oneliner` settle the case to `OnelinerState::Empty`
//! when no content transcript exists and no recording is still Pending.
mod common;
+3 -2
View File
@@ -33,8 +33,9 @@ start per-user hotwords tuning.
2. Note the case UUID the script prints.
3. Copy `$DATA_PATH/<slug>/open/<case_id>/<timestamp>.m4a`
`tests/fixtures/dictations/<name>.m4a`.
4. Copy `$DATA_PATH/<slug>/open/<case_id>/<timestamp>.transcript.txt`
`tests/fixtures/dictations/<name>.expected.txt`.
4. Extract the transcript from the recording metadata sidecar:
`jq -r '.transcript.text' $DATA_PATH/<slug>/open/<case_id>/<timestamp>.json`
→ write it to `tests/fixtures/dictations/<name>.expected.txt`.
5. **Review the transcript** — fix any Whisper errors by hand. This is the
golden reference; accept nothing that is actually wrong.
6. If the fixture tests Fachvokabular, add `<name>.hotwords.txt` with the