feat: pass podcast_config and script_directions to Podcaster API (#394)

* feat: pass podcast_config and script_directions to Podcaster API Add config/podcast.json with default podcast_config (Claracle show identity, hosts Theo/Vera with voice and style settings) and script_directions (opening/closing cues from the editorial style guide, episode style, and Summer Sport music mix instructions). Update scripts/podcaster_handoff.py to load the config file and include podcast_config and script_directions in the JSON payload sent to the Podcaster endpoint. Adds --podcast-config CLI flag with auto-discovery of config/podcast.json at the repo root. Closes #385 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve PR review comments — type validation and shared file fixes - Add type validation for podcast_config and script_directions in build_payload() (must be JSON objects) - Fix case-insensitive canary comparison in validate_output_safety - Block publishing on full canary leak instead of only warning - Move validate_output_safety import to top of test file - Update docs for actual log levels and multi-caller coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 12, 2026 at 08:39 UTC 3cfdf4f0bab4e4833632361daeb8b6f5cded53c2
3 files changed +95 -10
config/podcast.json new
+55
@@ -0,0 +1,55 @@
1 +{
2 + "podcast_config": {
3 + "name": "Claracle",
4 + "url": "https://www.claracle.com",
5 + "spoken_site": "www.claracle.com",
6 + "ai_voice_disclosure": "Both hosts on this show are AI-generated synthetic voices, not human presenters.",
7 + "hosts": [
8 + {
9 + "name": "Theo",
10 + "voice": "fable",
11 + "style": "Bright and energetic. Leads with enthusiasm, connects themes with forward momentum. Presents signal picks with clarity and genuine curiosity."
12 + },
13 + {
14 + "name": "Vera",
15 + "voice": "alloy",
16 + "style": "Calm, dry, and measured. Pressure-tests claims with specificity. Challenges hype with evidence, not dismissiveness. Lets pauses land."
17 + }
18 + ]
19 + },
20 + "script_directions": {
21 + "opening_cues": {
22 + "cold_open": "One provocative stat or question from this week's data. Grab attention in under 30 seconds.",
23 + "ai_disclosure": "Must appear in the first 60 seconds: explicitly state this episode uses AI-generated voice narration.",
24 + "suggested_phrasing": "This episode of Signal Check uses AI-generated voice narration from SquadScope's weekly analysis. Our hosts are synthetic — the data is real."
25 + },
26 + "closing_cues": {
27 + "corrections_path": "If we got something wrong, file an issue at https://github.com/jmservera/SquadScope/issues",
28 + "ai_disclosure_repeat": "Repeat AI voice disclosure in the outro.",
29 + "source_article_link": "Direct listeners to the full source article URL."
30 + },
31 + "episode_style": {
32 + "format": "Two-host conversational podcast, 8-10 minutes, 1200-1700 words.",
33 + "tone": "Conversational, not performative. Opinionated, not reckless. Concise, not rushed. Funny, not cruel. Accessible, not dumbed-down.",
34 + "segment_order": ["Cold Open", "The Signal", "The Noise Check", "The Gap", "Receipts Round", "Week Ahead", "Outro"],
35 + "phrasing": "Active voice, concrete verbs. Lead with the insight, not the setup. Skeptic challenges must be specific and data-backed.",
36 + "distinctiveness": "Must not replicate or closely resemble any named podcast's style, segment labels, or trademarked phrasing."
37 + },
38 + "music_mix": {
39 + "track": "Summer Sport",
40 + "intro": {
41 + "description": "Full volume for 10 seconds, fade down under Host A's opening line, fade up briefly after Host A finishes, fade out before Host B begins.",
42 + "full_volume_seconds": 10,
43 + "fade_down_under": "Host A opening",
44 + "fade_up_after": "Host A opening",
45 + "fade_out_before": "Host B first line"
46 + },
47 + "outro": {
48 + "description": "Start at 1:15 of the song. Slow fade-up during the farewell exchange between hosts, then play to the end of the track.",
49 + "start_position": "1:15",
50 + "fade_up_during": "farewell exchange",
51 + "play_to_end": true
52 + }
53 + }
54 + }
55 +}
scripts/podcaster_handoff.py
+31
@@ -12,6 +12,7 @@ from urllib.parse import urljoin, urlparse
12
13 AUTH_HEADER = "x-podcaster-api-key"
14 DEFAULT_TIMEOUT_SECONDS = 30
15 +DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json"
16
17
18 class PodcasterHandoffError(RuntimeError):
@@ -27,6 +28,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
28 parser.add_argument("--publish-mode", default="normal", help="Publish mode; only normal is eligible for Podcaster handoff.")
29 parser.add_argument("--manifest", type=Path, help="Optional publish manifest used for article hash/source artifact metadata.")
30 parser.add_argument("--podcaster-dry-run", action="store_true", help="Ask Podcaster to validate without generating an episode; intended only for the manual smoke workflow.")
31 + parser.add_argument("--podcast-config", type=Path, default=None, help="Path to podcast config JSON (default: config/podcast.json relative to repo root).")
32 parser.add_argument("--endpoint", default=os.environ.get("PODCASTER_ENDPOINT", ""))
33 parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
34 return parser.parse_args(argv)
@@ -85,6 +87,20 @@ def _load_manifest(path: Path | None) -> dict[str, Any]:
87 return payload
88
89
90 +def _load_podcast_config(path: Path | None) -> dict[str, Any]:
91 + """Load the podcast config file containing podcast_config and script_directions."""
92 + config_path = path if path is not None else DEFAULT_PODCAST_CONFIG_PATH
93 + if not config_path.exists():
94 + return {}
95 + try:
96 + payload = json.loads(config_path.read_text(encoding="utf-8"))
97 + except (OSError, json.JSONDecodeError) as exc:
98 + raise PodcasterHandoffError(f"Podcast config could not be read: {config_path}") from exc
99 + if not isinstance(payload, dict):
100 + raise PodcasterHandoffError(f"Podcast config must be a JSON object: {config_path}")
101 + return payload
102 +
103 +
104 def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, str]]:
105 refs: list[dict[str, str]] = []
106 for artifact in manifest.get("source_artifacts", []):
@@ -132,6 +148,7 @@ def build_payload(
148 publish_run_id: str,
149 publish_mode: str = "normal",
150 manifest_path: Path | None = None,
151 + podcast_config_path: Path | None = None,
152 podcaster_dry_run: bool = False,
153 ) -> dict[str, Any]:
154 manifest = _load_manifest(manifest_path)
@@ -154,6 +171,19 @@ def build_payload(
171 source_refs = _source_artifact_refs(manifest)
172 if source_refs:
173 payload["source_artifacts"] = source_refs
174 +
175 + podcast_cfg = _load_podcast_config(podcast_config_path)
176 + if "podcast_config" in podcast_cfg:
177 + val = podcast_cfg["podcast_config"]
178 + if not isinstance(val, dict):
179 + raise PodcasterHandoffError("podcast_config must be a JSON object")
180 + payload["podcast_config"] = val
181 + if "script_directions" in podcast_cfg:
182 + val = podcast_cfg["script_directions"]
183 + if not isinstance(val, dict):
184 + raise PodcasterHandoffError("script_directions must be a JSON object")
185 + payload["script_directions"] = val
186 +
187 if podcaster_dry_run:
188 payload["dry_run"] = True
189 return payload
@@ -232,6 +262,7 @@ def main(argv: list[str] | None = None) -> int:
262 publish_run_id=args.publish_run_id,
263 publish_mode=args.publish_mode,
264 manifest_path=args.manifest,
265 + podcast_config_path=args.podcast_config,
266 podcaster_dry_run=args.podcaster_dry_run,
267 )
268 post_handoff(endpoint, api_key, payload, timeout=args.timeout)
tests/test_podcaster_handoff.py
+9 -10
@@ -147,16 +147,15 @@ class PodcasterHandoffTests(unittest.TestCase):
147 self.assertEqual(req.get_header("X-podcaster-api-key"), "super-secret-value")
148 self.assertEqual(req.get_header("Content-type"), "application/json")
149 sent_payload = json.loads(req.data.decode("utf-8"))
150 - self.assertEqual(
151 - sent_payload,
152 - {
153 - "week": "2026-W23",
154 - "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
155 - "article_path": "content/weekly/2026/W23.md",
156 - "publish_run_id": "123456789",
157 - "publish_mode": "normal",
158 - },
159 - )
150 + self.assertEqual(sent_payload["week"], "2026-W23")
151 + self.assertEqual(sent_payload["article_url"], "https://jmservera.github.io/SquadScope/weekly/2026/w23/")
152 + self.assertEqual(sent_payload["article_path"], "content/weekly/2026/W23.md")
153 + self.assertEqual(sent_payload["publish_run_id"], "123456789")
154 + self.assertEqual(sent_payload["publish_mode"], "normal")
155 + self.assertIn("podcast_config", sent_payload)
156 + self.assertEqual(sent_payload["podcast_config"]["name"], "Claracle")
157 + self.assertIn("script_directions", sent_payload)
158 + self.assertIn("music_mix", sent_payload["script_directions"])
159 self.assertNotIn("super-secret-value", stdout.getvalue())
160
161 def test_non_normal_publish_mode_skips_without_calling_podcaster(self) -> None: