Broadcast — Healthcare AI News Briefing
An automated pipeline that turns the day's healthcare AI news into one spoken-word audio episode, end to end: ingest → dedup/rank → evidence-pinning → script generation → AI narration → QA gate → audio synthesis → distribution artifacts. Every stage is a standalone, independently-tested Python module in scripts/; orchestrate.py wires them into one real run.
Read this whole file before running anything live — several of the operational risks below were only discovered by actually running the pipeline against real APIs, and repeating those mistakes wastes real (rate-limited, sometimes shared) API quota.
Prerequisites
- Python 3.12 (stdlib only — no
pip installneeded for any broadcast script). - Node 22, and the evidence-pinning-mcp server built once (
orchestrate.pyspawns it as a subprocess automatically — it does not need to already be running, just built):cd mcp/evidence-pinning && npm ci && npm run build GEMINI_API_KEYset in the environment. Used for embeddings (dedup/ranking), AI narration, and text-to-speech — all three stages fail without it.REGULATIONS_GOV_API_KEY(optional). Without it,regulations_govingest uses the sharedDEMO_KEY(10 req/hr, no registration) — that quota is shared with every otherDEMO_KEYcaller globally, not scoped to this pipeline, so it can be exhausted by unrelated traffic regardless of how often this pipeline actually runs. A free key from api.data.gov raises that to 1000 req/hr, scoped to just this pipeline. Get one and set this env var wheneverregulations_gov— aregulatory-category, highest-authority-floor source — matters enough that you don't want its reliability depending on an unrelated party's request volume.
Running one episode
python skills/broadcast/scripts/orchestrate.py --data-dir ~/.broadcast-data \
[--date YYYY-MM-DD] # default: today
[--max-results-per-source N] # default: 10 — keep low for a quick/cheap test run
[--synth-delay-seconds N] # default: 6.0 — pacing between TTS calls, see risks below
[--no-narration] # skip AI narration, ship plain mechanical text
[--narration-success-threshold N] # default: 0.7 — episode-level narration fallback threshold
[--no-audio-normalize] # skip per-segment peak normalization in the assembled episode
[--inter-segment-silence-ms N] # default: 400.0 — silence gap between segments (0 to disable)
[--dry-run] # ingest only, print a Gemini call estimate, spend zero quota — see below
[--no-audio-cache] # disable the per-segment synthesized-audio cache — see below
[--top-three-count N] # default: 3 — shrink to reduce a real episode's TTS call count
[--quick-hits-count N] # default: 7 — see --top-three-count
Use ~/.broadcast-data as the default --data-dir unless the user asks for somewhere else. It holds persistent state (dedup_store.json, evidence_store/, audio_cache/) and every run's output (episodes/<date>/{report.json,script.json,episode.wav}) — deduplication and evidence provenance only accumulate meaningfully if the same directory is reused run over run, and nothing else in this repo establishes a canonical location. Don't invent a different path per session; that silently defeats the whole story-continuity design. orchestrate.py creates the directory itself if it doesn't exist yet — no setup needed beforehand.
The per-segment audio cache (<data-dir>/audio_cache/, enabled by default) exists because a real run has seen 11 of 14 segments fail to a Gemini TTS rate limit — twice. Each segment's synthesized audio is cached by a hash of its own exact text, so a retry after waiting for quota to recover only re-synthesizes segments that actually failed last time, not the whole episode from scratch — and a truly fixed segment (like the disclosure text, identical every day) only ever needs synthesizing once, ever, shared across every future episode. It's content-addressed by text only, not text+voice+model — if the pinned voice or TTS model ever changes, clear <data-dir>/audio_cache/ manually first, or a retried episode could end up with an inconsistent voice across segments. Not auto-pruned — same status episodes/<date>/ was in before prune_episodes.py existed.
There's also a manually-triggered GitHub Actions workflow, .github/workflows/broadcast-live-smoke-test.yml (workflow_dispatch only, never on push/PR — it makes real external API calls). It has two jobs: a smoke test of the ingest adapters + embeddings, and a full real episode run via orchestrate.py. Triggering it always runs both jobs — there is no way to trigger just one.
Prefer running orchestrate.py directly in-session over triggering that workflow, whenever the session can (i.e. it has GEMINI_API_KEY and can reach the network directly): direct invocation lets you run exactly one thing — e.g. just live_smoke_test.py to check ingest health, or one orchestrate.py call with a low --max-results-per-source — while workflow_dispatch always fires both jobs together, including the TTS-quota-heavy full episode run, whether you wanted that or not. That mismatch is exactly how this project has burned Gemini TTS quota before: a reconnaissance trigger meant to check one small thing also silently re-ran a full episode in the background. Reach for workflow_dispatch only when the session itself can't execute Python/reach the network, or when you specifically need to confirm behavior in the CI environment rather than locally.
Estimating cost before a real run
--dry-run runs real ingestion (free — no fetch_fn call ever touches Gemini) and then stops, printing an estimate of how many Gemini calls a real run with this exact config would make, instead of spending any of that quota to find out. GEMINI_API_KEY does not need to be set to use --dry-run — this is the one CLI mode that doesn't require it, precisely so it can answer "is this worth it" before you've committed to getting a key at all:
{"run_date": "2026-09-03", "dry_run": true, "ingest_failed": [...], "items_ingested": 23, "embed_calls_estimate": 23, "narration_calls_estimate_max": 10, "synth_calls_estimate_max": 14}
embed_calls_estimate is exact — every ingested item gets exactly one embedding call. narration_calls_estimate_max/synth_calls_estimate_max are genuine upper bounds, not predictions: computing the real, smaller number requires ranking, which itself needs embeddings (the one cost --dry-run exists to avoid paying for) — so these are capped at this pipeline's real, fixed selection ceiling (rank.DEFAULT_TOP_THREE_COUNT + rank.DEFAULT_QUICK_HITS_COUNT story segments, plus intro/disclosure/quick_hits_transition/outro), which same-day/rolling-window dedup can only ever shrink, never exceed. No evidence-pinning-mcp server is spawned and nothing is persisted to --data-dir in this mode.
Run this before a real episode whenever you're unsure how expensive it would be, or whenever a source's feed_url has recently changed (an RSS feed's real item count is uncapped by --max-results-per-source — see "Adding, removing, or tuning an ingest source" below — so its true embedding-call cost can only be known by actually checking, not by reading config).
Scheduling
.github/workflows/broadcast-scheduled.yml runs orchestrate.py on a cron cadence (daily at 12:00 UTC by default — a placeholder, change the cron expression to whatever cadence gets decided). It's disabled by default on purpose — a schedule: trigger has no native "off" switch in GitHub Actions, so the job itself is gated on a repository variable:
Settings -> Secrets and variables -> Actions -> Variables -> New repository variable:
BROADCAST_SCHEDULE_ENABLED = true
Until that variable is set, every scheduled firing shows as skipped in the Actions UI (visibly, not silently absent) and spends zero Gemini quota. Run --dry-run (see above) with a realistic --max-results-per-source first to know the real per-run cost before setting this variable — that's the whole reason --dry-run exists.
workflow_dispatch (manual trigger) always runs regardless of the variable — a human manually triggering this is already an explicit decision; the gate exists specifically to keep the unattended cron trigger inert until opted into.
--data-dir persistence in this workflow is best-effort, not durable. GitHub Actions runners are ephemeral, so dedup_store.json/evidence_store continuity across scheduled runs depends on actions/cache (a rolling per-run-id cache key with a prefix restore, since a cache entry can't be overwritten once created) — GitHub evicts unused cache entries after roughly 7 days and caps total repo cache size at 10GB. A real durable --data-dir most likely belongs in whatever repo ends up hosting distribution (see "No real hosting" below) once that exists.
This workflow does not run distribute.py — publishing needs a real --base-url a hosting target actually serves, which doesn't exist yet. It only produces the episode; wiring in real publishing is a follow-up once hosting is decided.
Reading the result — what "success" actually looks like
report.json (also printed to stdout) is the thing to read, not just the process exit code (which is 1 whenever episode_produced is false, even though everything upstream may have worked correctly):
qa_passed— structural + grounding checks on the script itself (intro/outro present, every story traces to a real pinned claim, no leaked excluded stories). Nothing to do with audio.episode_produced—trueonly if every segment's audio synthesized. One segment failing withholds the whole episode's audio (deliberate — a partial episode isn't assembled with a silent gap). Checksynth_failedfor which segments and why.narration_attempted/narration_succeeded/narration_success_rate/narration_episode_level_fallback/narration_failures— the AI narration layer's own results. A narration failure is not a pipeline failure — it's a best-effort enhancement with an automatic two-tier fallback (per-segment, then whole-episode) to the original mechanical text, by design. A low success rate orepisode_level_fallback: truemeans that day's episode shipped with plainer prose, not that anything is broken. These fields are allnullwhen--no-narrationwas passed (distinguishable from a real 0/0 result).ingest_failed— per-source ingest failures. A source documented inconfig/sources.jsonasfeed_url_verified: falsefailing is expected every run (see below), not a bug to chase — check the registry entry first before investigating.source_utilization— per-source breakdown of what happened to that run's candidates:candidates(survived same-day-dedup and got scored),selected_top_three/selected_quick_hits/selected_total,not_selected(considered, lost on relevance score),dropped_duplicates(same-day duplicate, never independently considered), andselection_rate(selected_total / candidates,nullwhencandidatesis 0). Pure observability, not a QA gate — nothing here fails a run, and a source with zero candidates today might just mean nothing newsworthy happened, not that it's being starved. A single run's numbers are noisy on their own; the useful read is comparing this field across several days'episodes/<date>/report.jsonfiles to spot a source that's never winning a slot, not any one day's snapshot. Sources with zero candidates this run don't appear in this dict at all, rather than a fabricated zero-row.
That rolling, multi-day view is source_health_report.py:
python skills/broadcast/scripts/source_health_report.py --data-dir ~/.broadcast-data [--days N] # default: 30
Sums source_utilization across every episodes/<date>/report.json in the window, prints a table sorted by pooled selection_rate (most-starved first), and separately flags any registered source with zero candidates anywhere in the whole window — the clearest "worth asking a human about" signal it can produce. Same discipline as the field it aggregates: nothing here is a QA gate, and it can't tell a genuinely-starved source apart from one that's just legitimately quiet that month — it's a diagnostic to prompt a question, not a verdict. Read-only, standalone, never called automatically.
qa_checks has the same rolling-view gap source_utilization had before source_health_report.py — one run's pass/fail is a snapshot, not a trend. qa_gate_history.py closes it differently: instead of reimplementing pass-rate/regression logic a third time, it flattens qa_checks across a window into skills/agent-eval/'s own JSONL schema (one row per episode/check pair, category = check name) and hands off to that skill's already-tested score_eval.py for the actual aggregation, regressions, and CI gate:
python skills/broadcast/scripts/qa_gate_history.py --data-dir ~/.broadcast-data --out qa_results.jsonl [--days N]
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --fail-under 0.9
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --baseline last_weeks_qa_results.jsonl --fail-on-regression
A documented file-format handoff, not a cross-skill import — qa_gate_history.py has no dependency on agent-eval being present to run; the second command is only useful when it is.
A real, unedited (trimmed for length) example from an actual live run, captured before healthcare_it_news was removed from the registry (permanently WAF-blocked, no viable RSS alternative found — see "Adding, removing, or tuning an ingest source" below) and before source_utilization existed — qa_passed: true and narration mostly succeeded, but episode_produced is false purely because of a TTS rate limit, not a script problem:
{
"run_date": "2026-09-02",
"ingest_failed": [{"source_key": "healthcare_it_news", "error": "HTTPError: HTTP Error 403: Forbidden"}],
"top_three_count": 3,
"quick_hits_count": 7,
"pinned_count": 10,
"segment_count": 14,
"narration_attempted": 10,
"narration_succeeded": 9,
"narration_success_rate": 0.9,
"narration_episode_level_fallback": false,
"narration_failures": [
{"canonical_id": "pmid:42644472", "reasons": ["narration length ratio 0.19 outside [0.4, 2.0]"]}
],
"qa_passed": true,
"qa_checks": [
{"check": "has_intro", "passed": true, "detail": ""},
{"check": "story_segments_grounded", "passed": true, "detail": ""}
],
"synth_failed": [
{"segment_type": "top_three_item", "canonical_id": "pmid:42644472", "error": "HTTPError: HTTP Error 429: Too Many Requests"}
],
"episode_produced": false
}
Debugging a failed or partial episode
Work through report.json in this order — nearly every failure traces to one of these, not a new regression:
qa_passed: false— look atqa_checksfor entries withpassed: false; each one'sdetailnames the exact structural problem (a missing intro/outro, a story segment missingclaim_id, etc.). This should essentially never happen from an unmodifiedorchestrate.pyrun —script_gen.pyalready enforces these invariants when it builds the script, andqa_gate.pyonly re-checks them as defense in depth. A failure here points to a real upstream bug, not something to route around or re-run past.episode_produced: falsebutqa_passed: true— the script was fine; audio synthesis failed for at least one segment. Checksynth_failed: if every entry readsHTTP Error 429: Too Many Requests, that's the known Gemini TTS rate limit (see Known operational risks below) — wait for quota to recover before doing anything else, don't immediately re-run. Any other error string is a real, new failure worth investigating on its own terms.ingest_failednon-empty — a source documented inconfig/sources.jsonasfeed_url_verified: falsefailing is expected every run; check the registry entry'sfeed_url_verified_notefirst. Any other source failing is new: check the error string — a previously-working source returning a different error than before usually means the feed URL or endpoint changed upstream, not a bug in this pipeline.- Low
narration_success_rateornarration_episode_level_fallback: true— not a failure at all. This is the narration layer's own designed circuit breaker, working as intended;narration_failureslists which stories fell back and the specific grounding-check reason (an ungrounded span, a dropped hedge, an out-of-range length ratio). The episode still ships — just with plainer prose for that story or that day.
Publishing an episode
python skills/broadcast/scripts/distribute.py --data-dir ~/.broadcast-data --date YYYY-MM-DD \
--publish-dir <dir> --base-url <public-url> --feed-link <url> \
[--feed-title "..."] [--feed-description "..."] [--feed-author "..."] \
[--itunes-category "..."] # default: Technology
[--itunes-explicit true|false] # default: false
[--itunes-type episodic|serial] # default: episodic
[--itunes-author "..."] # overrides --feed-author for itunes:author specifically
[--itunes-subtitle "..."]
[--itunes-image-url <url>] # square, 1400-3000px, JPEG/PNG, no transparency — see below
[--itunes-owner-name "..."] [--itunes-owner-email "..."] # <itunes:owner> needs BOTH to appear
This reads the episode orchestrate.py already produced and writes GitHub-Pages-ready files to --publish-dir: the audio file, an RSS feed.xml (RSS 2.0 plus the itunes: namespace — category/explicit/type/author/duration/etc.), and a matching Obsidian vault-note markdown file. It does not push, host, or deploy anything — that's a deliberate, separate, human-driven step. The RSS feed's URLs are only real once --publish-dir's contents are actually deployed to --base-url.
--itunes-image-url (cover art) is the one thing this can't produce itself — this pipeline has no way to generate real artwork, and Apple Podcasts won't list a show without one (square, 1400–3000px, JPEG/PNG, no transparency). Every other itunes: tag works and is emitted correctly with sensible defaults even with no image set; add --itunes-image-url once real art exists, no other change needed.
The vault note is markdown output only, not a vault write — landing it in a real Obsidian vault requires a live session with the wiki-operator skill's /source command and a connected obsidian-vault MCP server. distribute.py deliberately never writes to a vault directly.
Adding, removing, or tuning an ingest source
Every source lives in config/sources.json, validated at load time by source_registry.validate_registry() — a malformed entry raises a specific RegistryValidationError there rather than surfacing as a confusing KeyError three stages downstream.
- Adding an RSS-backed source (no code changes needed): add an entry with
"key","name","category"(an existing category, or a new one under"categories"— see "tuning" below), and"feed_url". Any source with afeed_urlfield is automatically dispatched byorchestrate.py's_fetch_for_source()toingest.fetch_rss(), the same generic parser already handling four different feeds' real-world quirks (non-RFC-822pubDateformats, a corrupted<link>field, etc. — seeingest.py's_parse_rss_pubdate()if a new feed's dates come back wrong). Leave"feed_url_verified": falseuntil you've actually confirmed it live withlive_smoke_test.py(see below) — every currently-verified source in this file was confirmed that way, never assumed correct from the URL alone. Don't trust a guessed URL, even from a search result:healthcare_it_news(Healthcare IT News) was registered this way, found genuinely, permanently WAF/Cloudflare-blocked (HTTP 403 on the real URL and three guessed variants, even with a full browser-like header set), and was removed entirely rather than left as a documented-broken source indefinitely. Its replacement,hit_consultant, is currently registered with"feed_url_verified": falsefor exactly this reason — its URL came from a web search and a SWOT comparison against Healthcare Dive (picked partly because Healthcare Dive is named in third-party scraping guides as an example of a Cloudflare-protected site — likely the same failure mode that killedhealthcare_it_news), not a live fetch. It needs a reallive_smoke_test.pyrun before that flag flips totrue. - Adding a query-based or fixed-endpoint source (like
pubmed/arxiv/regulations_gov/fda_maude, ormedrxiv/fda_guidance) needs real code, not just config: a newfetch_*()function iningest.py(see those seven functions for the two existing patterns) and a new dispatch branch inorchestrate.py's_fetch_for_source()keyed on the source's"key". Without both, that source raisesValueErrorat ingest time.fda_maudeis a good example of the discipline this section otherwise only describes for RSS sources: it's registered but itsquery_notesays plainly it's NOT yet live-verified — documented field names aren't the same as a confirmed real response, the same lessonhealthcare_it_newstaught the hard way. - Removing a source: delete its entry from
"sources"— nothing else references sources by a fixed list,ingest_all()just iterates whatever's currently in the registry. - Tuning relevance scoring:
authority_floor(0–1) andhalf_life_days(>0) are set per-category, not per-source (seesource_registry.py's docstring for what they control — a higher floor and longer half-life age a story more slowly). Every category and query inconfig/sources.jsonalready carries a*_notefield marking it as "a working default, not a validated measurement" — change these against real episode output, not intuition, and update the note to record why. - After any change, run
python skills/broadcast/scripts/live_smoke_test.py— it's cheap (real ingest + embedding calls, no TTS or narration spend) and will confirm the new or changed source actually returns real items before it's trusted in a full, expensive episode run.
Verifying a code change to this pipeline
Every module in scripts/ has a matching test_*.py, all stdlib unittest, no network calls (network-touching functions like synthesize_text() or generate_narration() are injectable and faked in tests — see any test_orchestrate.py RunEpisodeWiring test for the pattern). Run the full suite before trusting any change:
for f in skills/broadcast/scripts/test_*.py; do python3 "$f"; done
test_evidence.py, test_evidence_pinning_client.py, and test_qa_gate.py will report their evidence-pinning-mcp-server-dependent tests as skipped, not failed, if that server isn't built yet — a suite full of OK (skipped=N) can look deceptively clean. Build it first to actually exercise those tests for real:
cd mcp/evidence-pinning && npm ci && npm run build
ci.yml already runs the full suite (server built) on every push/PR — this is what to run locally before pushing, to catch a failure before CI does, not a substitute for it.
Retention
Four different stores live under --data-dir, each retained (or deliberately not) on purpose — check this before assuming any of them just grows forever:
dedup_store.json's rolling-window entries are already pruned automatically on everyorchestrate.pyrun (dedup_store.prune_old_entries(), wired in viarank.py, default 14-day window) — no action needed here, this one never grows unbounded.episodes/<date>/(the actual per-episode script/audio output) is not pruned automatically — a human may not have rundistribute.pyagainst a given episode yet, may want it kept as their own archive, or may be actively debugging it, so silent automatic deletion felt like the wrong default here. Useprune_episodes.pyexplicitly instead:
Dry-run by default — it only prints what would be deleted and changes nothing on disk. Passpython skills/broadcast/scripts/prune_episodes.py --data-dir ~/.broadcast-data [--retention-days N] # default: 90--applyto actually remove stale episode directories.evidence_store/(evidence-pinning-mcp's own state) is deliberately never pruned by anything in this pipeline — it's an append-only provenance log by design (seemcp/evidence-pinning/README.md), meant to keep a claim's full history queryable indefinitely. Pruning it would defeat its actual purpose, not just free disk space.audio_cache/(per-segment synthesized-audio cache, see "Running one episode" above) is not pruned automatically — no tool exists for this yet, an accepted gap for now rather than something silently ignored. It grows slowly: most of it is genuinely per-day (each story's own text is new), but fixed boilerplate (the disclosure segment) permanently dedupes to a single entry across every episode ever run.
Known operational risks — read before running live
- Gemini TTS rate-limits under realistic call volume, confirmed live more than once — most recently 11 of 14 segments failing to
HTTP 429, twice on separate real runs, and a third real run (the default-sized 10-story episode) failing outright — every segment after the first few hitHTTP 429,episode_produced: false— against an unbilled (free-tier)GEMINI_API_KEY. Never retrigger a failed or in-flight run back-to-back — wait for it to fully finish first. A blind retry is itself another request competing for the same already-exhausted quota; this made a real rate-limit situation worse in this project's own history, not better.synthesize_text()'s retry policy (gemini_retry.py) already honors the server'sRetry-Afterheader;--synth-delay-secondsadds further pacing; the per-segmentaudio_cache/(see "Running one episode" above) means a retry, once you do run one, only re-synthesizes segments that actually failed last time, not the whole episode. If you see a wall ofHTTP 429insynth_failed, wait — don't immediately re-run. If runs keep failing outright on a free-tier key,--top-three-count/--quick-hits-count(see "Running one episode" above) shrink the real TTS call count for the next attempt — the free lever to pull before paid Gemini quota. narrate.py's pinned model was chosen from live reconnaissance, not assumed — see its module docstring for the full account (a deprecated model, two overloaded newer ones, then a working one). If narration starts failing broadly, check whether that model id is still available/healthy before assuming a code regression.
What this skill does not do yet
- Scheduling exists but is off by default.
.github/workflows/broadcast-scheduled.yml(see "Scheduling" above) will runorchestrate.pyon a cron cadence onceBROADCAST_SCHEDULE_ENABLEDis set — until then, every firing is a visible no-op. It doesn't publish (distribute.py) yet either way, pending a hosting decision. - No real hosting.
distribute.py's output must be manually deployed (e.g. to GitHub Pages) for its feed to be reachable at--base-url. - No cover art.
distribute.pynow emits the fullitunes:namespace (category, explicit, type, author, subtitle, owner, per-episode duration) — see "Publishing an episode" above — butitunes:imageis left for a human to supply once real artwork exists; this pipeline can't generate it. Apple Podcasts specifically won't list a show without one; every other podcast app and every other tag works fine in the meantime. - No cross-story "digest" synthesis. Narration is strictly per-story, isolated to that story's own text — a deliberate scope decision made after research into hallucination risk in broader-context AI summarization, not an oversight. See
narrate.py's docstring for the reasoning. - No dollar-cost tracking.
--dry-run(see above) gives a pre-flight call-count estimate (exact for embeddings, a safe upper bound for narration/TTS), which is what actually would have prevented this project's own real quota-exhaustion incidents — but it doesn't know or report actual pricing, and there's still no cumulative spend tracking across runs. - Retention for
episodes/<date>/is opt-in, not automatic — see the Retention section above;prune_episodes.pyexists but has to be run deliberately, nothing calls it on a schedule.
Output discipline
- Always report
qa_passed,episode_produced, and the narration/synth failure buckets together when summarizing a run — a summary that omits them can make a partially-failed episode look clean. - Never say an episode was "published" unless
distribute.pyhas run and its--publish-diroutput has actually been deployed somewhere reachable at--base-url. Writingfeed.xmllocally is not publishing. - A low narration success rate is informational, not an error — don't treat it with the same urgency as a QA or synthesis failure.
- If
synth_failedshowsHTTP 429, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).