diff --git a/crates/cm-api/src/podcast.rs b/crates/cm-api/src/podcast.rs index 29b475a..0822dcd 100644 --- a/crates/cm-api/src/podcast.rs +++ b/crates/cm-api/src/podcast.rs @@ -62,6 +62,15 @@ impl Script { /// /// A continuation line (no speaker prefix) belongs to the turn above it, so a /// wrapped paragraph stays one turn rather than becoming a new one. +/// +/// **Markdown emphasis around the label is accepted**, because a model asked +/// for `HOST:` in a markdown file writes `**HOST:**` — measured, on the first +/// script this pipeline ever rendered (mission `01a0c9c4`). `split_once(':')` +/// then yields `**HOST`, the `*` fails the uppercase test, every line falls +/// through to the continuation branch with no turn to attach to, and the +/// whole episode parses to nothing. The skill asks for the bare form; the +/// parser accepts the form a writer actually produces, because the parser is +/// the deterministic half of that pair. pub fn parse_script(md: &str) -> Script { let mut title = String::new(); let mut turns: Vec = Vec::new(); @@ -82,17 +91,22 @@ pub fn parse_script(md: &str) -> Script { continue; } match line.split_once(':') { - Some((who, said)) - if !who.is_empty() - && who.len() <= 12 - && who - .chars() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ' ') => + Some((who_raw, said_raw)) + if { + let who = strip_emphasis(who_raw); + !who.is_empty() + && who.len() <= 12 + && who + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ' ') + } => { - let text = said.trim(); + let who = strip_emphasis(who_raw); + let text = strip_emphasis(said_raw); + let text = text.as_str(); if !text.is_empty() { turns.push(Turn { - speaker: who.trim().to_string(), + speaker: who, text: text.to_string(), }); } @@ -110,6 +124,15 @@ pub fn parse_script(md: &str) -> Script { } +/// Trim markdown emphasis and surrounding whitespace from a fragment. +/// +/// `**HOST` -> `HOST`, `** Welcome back.` -> `Welcome back.`, `_GUEST_` -> +/// `GUEST`. Only the wrapper is removed; emphasis INSIDE a sentence is left +/// alone, because it is the writer's and belongs in what is said. +fn strip_emphasis(s: &str) -> String { + s.trim().trim_matches(|c| c == '*' || c == '_').trim().to_string() +} + /// MPEG1 Layer III bitrates (kbps) and sample rates, indexed as the frame /// header encodes them. const MP3_BITRATES: [u32; 16] = [ @@ -430,6 +453,43 @@ impl AudioBackend for ElevenLabs { #[cfg(test)] mod tests { + + /// The format the first real script was written in. Bold speaker labels + /// parsed to ZERO turns before `strip_emphasis`, so the episode was + /// silently never rendered — and retried every two minutes. + #[test] + fn markdown_bold_speakers_are_still_speakers() { + let md = "# Podcast Script — 2026-09-22\n\n---\n\n **HOST:** Welcome back. Today's batch is about agents.\n\n **GUEST:** I want to start with the one that surprised me.\n\n _HOST_: And underscores count too.\n"; + let s = parse_script(md); + assert_eq!(s.title, "Podcast Script — 2026-09-22"); + assert_eq!(s.turns.len(), 3, "{:?}", s.turns); + assert_eq!(s.turns[0].speaker, "HOST"); + assert_eq!(s.turns[0].text, "Welcome back. Today's batch is about agents."); + assert_eq!(s.turns[1].speaker, "GUEST"); + assert_eq!(s.turns[2].speaker, "HOST"); + } + + /// The bare form the skill asks for keeps working, and a bolded line + /// that is NOT a speaker is still prose rather than a new turn. + #[test] + fn plain_speakers_work_and_bold_prose_is_not_a_turn() { + let md = "HOST: One.\n**Note:** this is an aside, not a speaker.\nGUEST: Two.\n"; + let s = parse_script(md); + assert_eq!(s.turns.len(), 2, "{:?}", s.turns); + assert!( + s.turns[0].text.contains("aside"), + "a non-speaker line belongs to the turn above: {:?}", + s.turns[0] + ); + assert_eq!(s.turns[1].speaker, "GUEST"); + } + + /// Emphasis inside a sentence is the writer's and must survive. + #[test] + fn emphasis_inside_speech_is_left_alone() { + let s = parse_script("**HOST:** it is *really* about tool use\n"); + assert_eq!(s.turns[0].text, "it is *really* about tool use"); + } use super::*; #[test] @@ -771,7 +831,18 @@ pub async fn render_pending( }; let script = parse_script(&md); if script.turns.is_empty() { - eprintln!("podcast: {} has no spoken turns — skipping", path.display()); + // A script that is present and yields nothing is NOT a transient + // failure: it will read the same on the next sweep, and the one + // after, forever. Before this, that is exactly what happened — + // mission 01a0c9c4 logged this line every two minutes with no + // episode and no tombstone, so nothing downstream could tell + // "not rendered yet" from "never will be". + eprintln!( + "podcast: {} has no spoken turns — recording it as unrenderable rather \ + than retrying a script that cannot change", + path.display() + ); + record_unrenderable_because(pool, mission_id, "unrenderable:no-turns").await; continue; } @@ -935,16 +1006,26 @@ async fn record_unrenderable(pool: &sqlx::PgPool, mission_id: uuid::Uuid, checko vault branch if it is wanted.", checkout.display() ); + record_unrenderable_because(pool, mission_id, "unrenderable").await; +} + +/// Write the tombstone, naming WHY in `rendered_by`. +/// +/// Every value starts with `unrenderable` so a reader keying on the prefix +/// still sees a tombstone, and the suffix says which dead end it was — a +/// missing script and an unparseable one are different bugs. +async fn record_unrenderable_because(pool: &sqlx::PgPool, mission_id: uuid::Uuid, why: &str) { let _ = sqlx::query( "INSERT INTO podcast_episodes (id, workspace_id, mission_id, episode_date, title, blob_key, bytes, duration_secs, rendered_by, script_sha) - SELECT $1, m.workspace_id, m.id, '', m.title, '', 0, 0, 'unrenderable', '' + SELECT $1, m.workspace_id, m.id, '', m.title, '', 0, 0, $3, '' FROM missions m WHERE m.id = $2 ON CONFLICT (mission_id) DO NOTHING", ) .bind(uuid::Uuid::now_v7()) .bind(mission_id) + .bind(why) .execute(pool) .await; } diff --git a/scripts/verify-mission-delivery.sh b/scripts/verify-mission-delivery.sh index 8a3cc26..544ea76 100755 --- a/scripts/verify-mission-delivery.sh +++ b/scripts/verify-mission-delivery.sh @@ -1594,6 +1594,7 @@ print("ok")' >/dev/null 2>&1 \ from podcast_episodes where mission_id='$mission';\"" | head -1 | tr -d '\r') case "$ep" in '') fail "cr: no podcast_episodes row — the render sweep never reached this mission" ;; + unrenderable:no-turns*) fail "cr: the script parsed to zero spoken turns — the writer's format and parse_script disagree" ;; unrenderable*) fail "cr: the episode is a tombstone (unrenderable) — the script was not found in the checkout OR the vault" ;; *' 0') fail "cr: an episode was recorded with zero duration: $ep" ;; *) pass "cr: audio rendered by $ep" ;;