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:
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user