fix: include article_content and article_title in podcaster handoff payload (#407)

Read the article file (resolved from repo-relative path) in build_payload() and include its content as 'article_content' in the payload dict. Extract the title from YAML front matter (title: field) or first # heading and include as 'article_title'. Truncate content at 50000 chars as a size guard. Closes #406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 12, 2026 at 12:22 UTC 885cb5560cf9ce866738001540e28bc3507ffe85
2 files changed +145 -1
scripts/podcaster_handoff.py
+54 -1
@@ -4,6 +4,7 @@ from __future__ import annotations
4 import argparse
5 import json
6 import os
7 +import re
8 from pathlib import Path
9 from typing import Any
10 from urllib import error, request
@@ -13,6 +14,8 @@ from urllib.parse import urljoin, urlparse
14 AUTH_HEADER = "x-podcaster-api-key"
15 DEFAULT_TIMEOUT_SECONDS = 30
16 DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json"
17 +REPO_ROOT = Path(__file__).resolve().parent.parent
18 +MAX_ARTICLE_CONTENT_CHARS = 50_000
19
20
21 class PodcasterHandoffError(RuntimeError):
@@ -123,6 +126,46 @@ def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, str]]:
126 return refs
127
128
129 +def _extract_title(content: str) -> str | None:
130 + """Extract article title from YAML front matter or first # heading."""
131 + # Try YAML front matter first
132 + if content.startswith("---"):
133 + end = content.find("\n---", 3)
134 + if end != -1:
135 + frontmatter = content[3:end]
136 + match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
137 + if match:
138 + title = match.group(1).strip().strip("\"'")
139 + if title:
140 + return title
141 + # Fall back to first # heading
142 + match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
143 + if match:
144 + return match.group(1).strip()
145 + return None
146 +
147 +
148 +def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tuple[str | None, str | None]:
149 + """Read article file content and extract title.
150 +
151 + Returns (content, title). Content is truncated to MAX_ARTICLE_CONTENT_CHARS.
152 + Returns (None, None) if the file does not exist or is empty.
153 + """
154 + resolved = repo_root / article_path
155 + if not resolved.exists():
156 + return None, None
157 + try:
158 + content = resolved.read_text(encoding="utf-8")
159 + except OSError:
160 + return None, None
161 + if not content.strip():
162 + return None, None
163 + title = _extract_title(content)
164 + if len(content) > MAX_ARTICLE_CONTENT_CHARS:
165 + content = content[:MAX_ARTICLE_CONTENT_CHARS]
166 + return content, title
167 +
168 +
169 def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
170 if not manifest:
171 return True
@@ -150,17 +193,27 @@ def build_payload(
193 manifest_path: Path | None = None,
194 podcast_config_path: Path | None = None,
195 podcaster_dry_run: bool = False,
196 + repo_root: Path | None = None,
197 ) -> dict[str, Any]:
198 manifest = _load_manifest(manifest_path)
199 if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
200 raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
201 + normalized_path = normalize_page_path(article_path)
202 payload: dict[str, Any] = {
203 "week": week,
204 "article_url": article_url,
160 - "article_path": normalize_page_path(article_path),
205 + "article_path": normalized_path,
206 "publish_run_id": publish_run_id,
207 "publish_mode": publish_mode,
208 }
209 +
210 + # Read article content and extract title
211 + root = repo_root if repo_root is not None else REPO_ROOT
212 + content, title = _read_article_content(normalized_path, repo_root=root)
213 + if content:
214 + payload["article_content"] = content
215 + if title:
216 + payload["article_title"] = title
217 article_sha = (
218 manifest.get("candidate", {}).get("summary_sha256")
219 if isinstance(manifest.get("candidate"), dict)
tests/test_podcaster_handoff.py
+91
@@ -49,6 +49,11 @@ class PodcasterHandoffTests(unittest.TestCase):
49 tests_root = Path(__file__).resolve().parent
50 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
51 manifest = self._write_manifest(Path(tmpdir))
52 + # Create article file so article_content is included
53 + article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
54 + article_dir.mkdir(parents=True)
55 + article_file = article_dir / "W23.md"
56 + article_file.write_text("---\ntitle: Week 23 Report\n---\n# Heading\nBody content here.\n", encoding="utf-8")
57
58 payload = podcaster_handoff.build_payload(
59 week="2026-W23",
@@ -57,6 +62,7 @@ class PodcasterHandoffTests(unittest.TestCase):
62 publish_run_id="123456789",
63 publish_mode="normal",
64 manifest_path=manifest,
65 + repo_root=Path(tmpdir),
66 )
67
68 self.assertEqual(payload["week"], "2026-W23")
@@ -74,6 +80,10 @@ class PodcasterHandoffTests(unittest.TestCase):
80 )
81 self.assertNotIn("force", payload)
82 self.assertNotIn("dry_run", payload)
83 + # article_content and article_title from the article file
84 + self.assertIn("article_content", payload)
85 + self.assertIn("Week 23 Report", payload["article_title"])
86 + self.assertIn("Body content here.", payload["article_content"])
87
88 def test_podcaster_dry_run_sets_payload_flag(self) -> None:
89 payload = podcaster_handoff.build_payload(
@@ -287,5 +297,86 @@ class PodcasterHandoffTests(unittest.TestCase):
297 )
298
299
300 + def test_build_payload_includes_article_content_from_file(self) -> None:
301 + with tempfile.TemporaryDirectory() as tmpdir:
302 + base = Path(tmpdir)
303 + article_dir = base / "content" / "weekly" / "2026"
304 + article_dir.mkdir(parents=True)
305 + article = article_dir / "W24.md"
306 + article.write_text("---\ntitle: My Title\n---\n# Heading\nHello world.\n", encoding="utf-8")
307 +
308 + payload = podcaster_handoff.build_payload(
309 + week="2026-W24",
310 + article_url="https://example.com/weekly/2026/w24/",
311 + article_path="content/weekly/2026/W24.md",
312 + publish_run_id="999",
313 + publish_mode="normal",
314 + podcaster_dry_run=True,
315 + repo_root=base,
316 + )
317 +
318 + self.assertEqual(payload["article_title"], "My Title")
319 + self.assertIn("Hello world.", payload["article_content"])
320 + self.assertIn("---\ntitle: My Title\n---", payload["article_content"])
321 +
322 + def test_build_payload_extracts_title_from_heading_when_no_frontmatter(self) -> None:
323 + with tempfile.TemporaryDirectory() as tmpdir:
324 + base = Path(tmpdir)
325 + article_dir = base / "content" / "weekly" / "2026"
326 + article_dir.mkdir(parents=True)
327 + article = article_dir / "W24.md"
328 + article.write_text("# My Heading Title\nSome content.\n", encoding="utf-8")
329 +
330 + payload = podcaster_handoff.build_payload(
331 + week="2026-W24",
332 + article_url="https://example.com/weekly/2026/w24/",
333 + article_path="content/weekly/2026/W24.md",
334 + publish_run_id="999",
335 + publish_mode="normal",
336 + podcaster_dry_run=True,
337 + repo_root=base,
338 + )
339 +
340 + self.assertEqual(payload["article_title"], "My Heading Title")
341 +
342 + def test_build_payload_truncates_large_article_content(self) -> None:
343 + with tempfile.TemporaryDirectory() as tmpdir:
344 + base = Path(tmpdir)
345 + article_dir = base / "content" / "weekly" / "2026"
346 + article_dir.mkdir(parents=True)
347 + article = article_dir / "W24.md"
348 + large_content = "# Title\n" + "x" * 60_000
349 + article.write_text(large_content, encoding="utf-8")
350 +
351 + payload = podcaster_handoff.build_payload(
352 + week="2026-W24",
353 + article_url="https://example.com/weekly/2026/w24/",
354 + article_path="content/weekly/2026/W24.md",
355 + publish_run_id="999",
356 + publish_mode="normal",
357 + podcaster_dry_run=True,
358 + repo_root=base,
359 + )
360 +
361 + self.assertEqual(len(payload["article_content"]), 50_000)
362 + self.assertEqual(payload["article_title"], "Title")
363 +
364 + def test_build_payload_missing_article_file_omits_content(self) -> None:
365 + with tempfile.TemporaryDirectory() as tmpdir:
366 + base = Path(tmpdir)
367 + payload = podcaster_handoff.build_payload(
368 + week="2026-W24",
369 + article_url="https://example.com/weekly/2026/w24/",
370 + article_path="content/weekly/2026/W24.md",
371 + publish_run_id="999",
372 + publish_mode="normal",
373 + podcaster_dry_run=True,
374 + repo_root=base,
375 + )
376 +
377 + self.assertNotIn("article_content", payload)
378 + self.assertNotIn("article_title", payload)
379 +
380 +
381 if __name__ == "__main__":
382 unittest.main()