feat(handoff): include historical context in podcaster payload (#508)

* feat(handoff): include historical context in podcaster payload Add month_synthesis and yearly_narrative to handoff payload for podcast script enrichment. Graceful fallback when files are missing. Closes #505 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(handoff): nest historical_context under script_directions Aligns with SquadScope-Podcaster contract where ScriptDirections.from_payload reads historical_context from the script_directions object. Resolves review feedback on PR #508. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(handoff): extract only Year in Review section from yearly narrative Addresses review feedback on PR #508: _read_historical_context() now uses _extract_markdown_sections() to extract only the '## Year in Review' section from yearly narrative pages, instead of sending the entire page body. Adds test_read_historical_context_extracts_only_year_in_review_section to verify multi-section yearly pages are handled correctly. 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 16, 2026 at 13:58 UTC 124d57dbcd167df1b5c498bde60641ace027f726
2 files changed +250
scripts/podcaster_handoff.py
+87
@@ -5,6 +5,7 @@ import argparse
5 import json
6 import os
7 import re
8 +from datetime import date
9 from pathlib import Path
10 from typing import Any
11 from urllib import error, request
@@ -18,6 +19,8 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
19 MAX_ARTICLE_CONTENT_CHARS = 50_000
20 MAX_SPOTIFY_TITLE_CHARS = 200
21 MAX_SPOTIFY_DESCRIPTION_CHARS = 4_000
22 +MAX_MONTH_SYNTHESIS_WORDS = 300
23 +MAX_YEARLY_NARRATIVE_WORDS = 500
24
25
26 class PodcasterHandoffError(RuntimeError):
@@ -255,6 +258,85 @@ def _truncate_text(value: str, limit: int) -> str:
258 return value[:limit]
259
260
261 +def _truncate_words(value: str, limit: int) -> str:
262 + words = value.split()
263 + return " ".join(words[:limit])
264 +
265 +
266 +def _strip_frontmatter(content: str) -> str:
267 + if not content.startswith("---"):
268 + return content.strip()
269 + end = content.find("\n---", 3)
270 + if end == -1:
271 + return content.strip()
272 + return content[end + 4 :].strip()
273 +
274 +
275 +def _extract_markdown_sections(content: str, headings: tuple[str, ...]) -> str | None:
276 + body = _strip_frontmatter(content)
277 + sections: list[str] = []
278 + for heading in headings:
279 + match = re.search(
280 + rf"^##\s+{re.escape(heading)}\s*$\n?(.*?)(?=^##\s+|\Z)",
281 + body,
282 + re.MULTILINE | re.DOTALL,
283 + )
284 + if not match:
285 + continue
286 + section_body = match.group(1).strip()
287 + section = f"## {heading}"
288 + if section_body:
289 + section += f"\n\n{section_body}"
290 + sections.append(section)
291 + combined = "\n\n".join(sections).strip()
292 + return combined or None
293 +
294 +
295 +def _read_historical_context(week: str, repo_root: Path) -> dict[str, str] | None:
296 + match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week)
297 + if not match:
298 + raise PodcasterHandoffError(f"Week must use YYYY-WNN format for historical context lookup: {week}")
299 +
300 + year = int(match.group("year"))
301 + week_number = int(match.group("week"))
302 + monday = date.fromisocalendar(year, week_number, 1)
303 +
304 + month_synthesis_path = repo_root / "data" / "analyzed" / f"{year}-{monday.month:02d}-month-synthesis.md"
305 + yearly_narrative_path = repo_root / "content" / "yearly" / f"{year}.md"
306 +
307 + historical_context: dict[str, str] = {}
308 +
309 + if month_synthesis_path.exists():
310 + try:
311 + month_synthesis = month_synthesis_path.read_text(encoding="utf-8")
312 + except OSError as exc:
313 + raise PodcasterHandoffError(
314 + f"Month synthesis file exists but could not be read: {month_synthesis_path} ({exc})"
315 + ) from exc
316 + extracted_sections = _extract_markdown_sections(month_synthesis, ("Month Synthesis", "Trend Arc"))
317 + if extracted_sections:
318 + historical_context["month_synthesis"] = _truncate_words(
319 + extracted_sections,
320 + MAX_MONTH_SYNTHESIS_WORDS,
321 + )
322 +
323 + if yearly_narrative_path.exists():
324 + try:
325 + yearly_narrative = yearly_narrative_path.read_text(encoding="utf-8")
326 + except OSError as exc:
327 + raise PodcasterHandoffError(
328 + f"Yearly narrative file exists but could not be read: {yearly_narrative_path} ({exc})"
329 + ) from exc
330 + extracted_yearly = _extract_markdown_sections(yearly_narrative, ("Year in Review",))
331 + if extracted_yearly:
332 + historical_context["yearly_narrative"] = _truncate_words(
333 + extracted_yearly,
334 + MAX_YEARLY_NARRATIVE_WORDS,
335 + )
336 +
337 + return historical_context or None
338 +
339 +
340 def _resolve_spotify_publish(config: dict[str, Any], *, week: str, article_title: str | None, article_summary: str | None) -> dict[str, Any]:
341 """Render spotify_publish templates into concrete values for the Podcaster API.
342
@@ -391,6 +473,11 @@ def build_payload(
473 if not isinstance(val, dict):
474 raise PodcasterHandoffError("script_directions must be a JSON object")
475 payload["script_directions"] = val
476 + historical_context = _read_historical_context(week, root)
477 + if historical_context:
478 + if "script_directions" not in payload:
479 + payload["script_directions"] = {}
480 + payload["script_directions"]["historical_context"] = historical_context
481 if "spotify_publish" in podcast_cfg:
482 val = podcast_cfg["spotify_publish"]
483 if not isinstance(val, dict):
tests/test_podcaster_handoff.py
+163
@@ -61,6 +61,22 @@ class PodcasterHandoffTests(unittest.TestCase):
61 )
62 return manifest
63
64 + def _write_historical_context(
65 + self,
66 + base: Path,
67 + *,
68 + month_synthesis: str | None = None,
69 + yearly_narrative: str | None = None,
70 + ) -> None:
71 + if month_synthesis is not None:
72 + month_path = base / "data" / "analyzed"
73 + month_path.mkdir(parents=True, exist_ok=True)
74 + (month_path / "2026-06-month-synthesis.md").write_text(month_synthesis, encoding="utf-8")
75 + if yearly_narrative is not None:
76 + yearly_path = base / "content" / "yearly"
77 + yearly_path.mkdir(parents=True, exist_ok=True)
78 + (yearly_path / "2026.md").write_text(yearly_narrative, encoding="utf-8")
79 +
80 def test_build_payload_uses_required_fields_and_real_optional_values(self) -> None:
81 tests_root = Path(__file__).resolve().parent
82 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
@@ -73,6 +89,27 @@ class PodcasterHandoffTests(unittest.TestCase):
89 "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Heading\nBody content here.\n",
90 encoding="utf-8",
91 )
92 + self._write_historical_context(
93 + Path(tmpdir),
94 + month_synthesis=(
95 + "---\n"
96 + "title: June 2026 Month Synthesis\n"
97 + "---\n\n"
98 + "## Month Synthesis\n\n"
99 + "June continued the agent-skills story.\n\n"
100 + "## Trend Arc\n\n"
101 + "- Agent skills kept accelerating.\n\n"
102 + "## Prediction Review\n\n"
103 + "Ignored.\n"
104 + ),
105 + yearly_narrative=(
106 + "---\n"
107 + "title: 2026 Yearly Narrative\n"
108 + "---\n\n"
109 + "## Year in Review\n\n"
110 + "The year kept compounding around agent packaging and trust gaps.\n"
111 + ),
112 + )
113
114 payload = podcaster_handoff.build_payload(
115 week="2026-W23",
@@ -121,6 +158,15 @@ class PodcasterHandoffTests(unittest.TestCase):
158 self.assertIn("Week 23 Report", payload["article_title"])
159 self.assertEqual(payload["article_summary"], "Week 23 summary.")
160 self.assertIn("Body content here.", payload["article_content"])
161 + self.assertEqual(
162 + payload["script_directions"]["historical_context"]["month_synthesis"],
163 + "## Month Synthesis June continued the agent-skills story. ## Trend Arc - Agent skills kept accelerating.",
164 + )
165 + self.assertEqual(
166 + payload["script_directions"]["historical_context"]["yearly_narrative"],
167 + "## Year in Review The year kept compounding around agent packaging and trust gaps.",
168 + )
169 + self.assertLess(len(json.dumps(payload)), 100_000)
170
171 def test_smoke_payload_matches_real_weekly_handoff_shape(self) -> None:
172 podcaster_root = Path(__file__).resolve().parents[2] / "SquadScope-Podcaster"
@@ -191,6 +237,123 @@ class PodcasterHandoffTests(unittest.TestCase):
237 self.assertEqual(built["source_artifacts"][0]["sources_failed"], ["rss"])
238 self.assertNotIn("sources_requested", built["source_artifacts"][1])
239
240 + def test_build_payload_omits_historical_context_when_both_files_are_missing(self) -> None:
241 + tests_root = Path(__file__).resolve().parent
242 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
243 + manifest = self._write_manifest(Path(tmpdir))
244 + article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
245 + article_dir.mkdir(parents=True)
246 + (article_dir / "W23.md").write_text(
247 + "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Heading\nBody content here.\n",
248 + encoding="utf-8",
249 + )
250 +
251 + payload = podcaster_handoff.build_payload(
252 + week="2026-W23",
253 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
254 + article_path="content/weekly/2026/W23.md",
255 + publish_run_id="123456789",
256 + publish_mode="normal",
257 + manifest_path=manifest,
258 + repo_root=Path(tmpdir),
259 + )
260 +
261 + self.assertNotIn("historical_context", payload.get("script_directions", {}))
262 +
263 + def test_read_historical_context_includes_available_file_when_other_is_missing(self) -> None:
264 + tests_root = Path(__file__).resolve().parent
265 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
266 + base = Path(tmpdir)
267 + self._write_historical_context(
268 + base,
269 + yearly_narrative=(
270 + "---\n"
271 + "title: 2026 Yearly Narrative\n"
272 + "---\n\n"
273 + "## Year in Review\n\n"
274 + "Only the yearly narrative is available.\n"
275 + ),
276 + )
277 +
278 + context = podcaster_handoff._read_historical_context("2026-W23", base)
279 +
280 + self.assertEqual(
281 + context,
282 + {"yearly_narrative": "## Year in Review Only the yearly narrative is available."},
283 + )
284 +
285 + def test_read_historical_context_truncates_to_word_budget(self) -> None:
286 + tests_root = Path(__file__).resolve().parent
287 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
288 + base = Path(tmpdir)
289 + month_words = " ".join(f"month{i}" for i in range(1, 351))
290 + trend_words = " ".join(f"trend{i}" for i in range(1, 101))
291 + yearly_words = " ".join(f"year{i}" for i in range(1, 651))
292 + self._write_historical_context(
293 + base,
294 + month_synthesis=(
295 + "---\n"
296 + "title: June 2026 Month Synthesis\n"
297 + "---\n\n"
298 + "## Month Synthesis\n\n"
299 + f"{month_words}\n\n"
300 + "## Trend Arc\n\n"
301 + f"{trend_words}\n"
302 + ),
303 + yearly_narrative=(
304 + "---\n"
305 + "title: 2026 Yearly Narrative\n"
306 + "---\n\n"
307 + "## Year in Review\n\n"
308 + f"{yearly_words}\n"
309 + ),
310 + )
311 +
312 + context = podcaster_handoff._read_historical_context("2026-W23", base)
313 +
314 + self.assertIsNotNone(context)
315 + assert context is not None
316 + self.assertLessEqual(
317 + len(context["month_synthesis"].split()),
318 + podcaster_handoff.MAX_MONTH_SYNTHESIS_WORDS,
319 + )
320 + self.assertLessEqual(
321 + len(context["yearly_narrative"].split()),
322 + podcaster_handoff.MAX_YEARLY_NARRATIVE_WORDS,
323 + )
324 + self.assertLessEqual(
325 + len(context["month_synthesis"].split()) + len(context["yearly_narrative"].split()),
326 + podcaster_handoff.MAX_MONTH_SYNTHESIS_WORDS + podcaster_handoff.MAX_YEARLY_NARRATIVE_WORDS,
327 + )
328 +
329 + def test_read_historical_context_extracts_only_year_in_review_section(self) -> None:
330 + tests_root = Path(__file__).resolve().parent
331 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
332 + base = Path(tmpdir)
333 + self._write_historical_context(
334 + base,
335 + yearly_narrative=(
336 + "---\n"
337 + "title: 2026 Yearly Narrative\n"
338 + "---\n\n"
339 + "## Year in Review\n\n"
340 + "The year kept compounding around agent packaging.\n\n"
341 + "## Methodology\n\n"
342 + "This section should NOT appear in the handoff.\n\n"
343 + "## Contributors\n\n"
344 + "Also should NOT appear.\n"
345 + ),
346 + )
347 +
348 + context = podcaster_handoff._read_historical_context("2026-W23", base)
349 +
350 + self.assertIsNotNone(context)
351 + assert context is not None
352 + self.assertIn("yearly_narrative", context)
353 + self.assertIn("agent packaging", context["yearly_narrative"])
354 + self.assertNotIn("Methodology", context["yearly_narrative"])
355 + self.assertNotIn("Contributors", context["yearly_narrative"])
356 +
357 def test_podcaster_dry_run_sets_payload_flag(self) -> None:
358 payload = podcaster_handoff.build_payload(
359 week="2026-W23",