feat: add rolling 4-week context report (closes #399) (#410)

Add generate_rolling_report() to scripts/generate_rollups.py that: - Reads the last 4 weekly summaries from content/weekly/ - Synthesizes them into a compact report with Active Trends, Trend Velocity (accelerating/new/decelerating/dying), Open Predictions, and Noise Patterns sections - Writes to content/rolling/last-month.md (overwritten each week) - Hooked into CLI via --rolling flag Uses existing WeeklySummary dataclass fields (signal, noise, gaps, conclusion, tags) to synthesize rather than enumerate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 12, 2026 at 12:36 UTC 1e2d409b12ab6b87f52593beb8c4552ac01abd2e
2 files changed +330
scripts/generate_rollups.py
+233
@@ -130,6 +130,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
130 default=PROJECT_ROOT / "content",
131 help="Root content directory for generated rollups.",
132 )
133 + parser.add_argument(
134 + "--rolling",
135 + action="store_true",
136 + default=False,
137 + help="Generate the rolling 4-week context report after rollups.",
138 + )
139 return parser.parse_args(argv)
140
141
@@ -474,9 +480,236 @@ def generate_rollups(analyzed_dir: Path, content_root: Path) -> list[Path]:
480 return written
481
482
483 +ROLLING_SECTIONS = [
484 + "Active Trends",
485 + "Trend Velocity",
486 + "Open Predictions",
487 + "Noise Patterns",
488 +]
489 +
490 +VELOCITY_LABELS = ("accelerating", "new", "decelerating", "dying")
491 +
492 +
493 +def _load_weekly_content(content_root: Path) -> list[WeeklySummary]:
494 + """Load weekly summaries directly from content/weekly/ (published pages).
495 +
496 + Falls back to data/analyzed/ format. Handles content/weekly files that
497 + may lack 'year' frontmatter by extracting it from the 'week' field.
498 + """
499 + weekly_dir = content_root / "weekly"
500 + if not weekly_dir.exists():
501 + return []
502 + paths = sorted(weekly_dir.rglob("W*.md"))
503 + summaries: list[WeeklySummary] = []
504 + for path in paths:
505 + if path.name == "_index.md":
506 + continue
507 + try:
508 + summaries.append(_load_weekly_summary(path))
509 + except (RollupError, KeyError, ValueError):
510 + continue
511 + return sorted(summaries, key=lambda s: (s.year, s.week_number))
512 +
513 +
514 +def _load_weekly_summary(path: Path) -> WeeklySummary:
515 + """Load a weekly summary from content/weekly/, tolerating missing 'year'."""
516 + frontmatter, body = analysis_gate.extract_frontmatter(path.read_text(encoding="utf-8"))
517 + week = str(frontmatter.get("week", ""))
518 + match = WEEK_PATTERN.fullmatch(week)
519 + if not match:
520 + raise RollupError(f"Invalid week slug in {path.name}")
521 +
522 + year = int(frontmatter.get("year", match.group("year")))
523 + date = analysis_gate.parse_datetime(frontmatter["date"])
524 +
525 + signal_noise_section = get_section_text(body, "Signal & Noise")
526 + if signal_noise_section:
527 + signal = normalize_text(signal_noise_section)
528 + noise = ""
529 + else:
530 + trend_analysis = get_section_text(body, "Trend Analysis")
531 + signal = get_subsection_text(trend_analysis, "Signal")
532 + noise = get_subsection_text(trend_analysis, "Noise")
533 +
534 + # Pull structured noise from frontmatter if available (newer format)
535 + signal_noise_fm = frontmatter.get("signal_noise")
536 + if signal_noise_fm and isinstance(signal_noise_fm, dict):
537 + noise_items = signal_noise_fm.get("noise", [])
538 + if noise_items and isinstance(noise_items, list):
539 + noise = "; ".join(str(n) for n in noise_items)
540 +
541 + blind_spots = get_section_text(body, "Blind Spots")
542 + if blind_spots:
543 + gaps = normalize_text(blind_spots)
544 + else:
545 + gaps = get_subsection_text(get_section_text(body, "What's Missing"), "Gaps")
546 +
547 + week_ahead = get_section_text(body, "The Week Ahead")
548 + if week_ahead:
549 + conclusion = normalize_text(week_ahead)
550 + else:
551 + conclusion = normalize_text(get_section_text(body, "Conclusion"))
552 +
553 + top_repo = str(frontmatter.get("top_repo", ""))
554 +
555 + return WeeklySummary(
556 + source_path=path,
557 + title=str(frontmatter.get("title", "")),
558 + date=date,
559 + week=week,
560 + year=year,
561 + month=date.month,
562 + tags=tuple(str(tag) for tag in frontmatter.get("tags", [])),
563 + repos_featured=int(frontmatter.get("repos_featured", 0)),
564 + top_repo=top_repo,
565 + featured_repos=extract_featured_repos(body, top_repo) if top_repo else (),
566 + summary=normalize_text(str(frontmatter.get("summary", ""))),
567 + signal=signal,
568 + noise=noise,
569 + gaps=gaps,
570 + conclusion=conclusion,
571 + )
572 +
573 +
574 +def _classify_velocity(tags_by_week: list[tuple[str, set[str]]]) -> dict[str, str]:
575 + """Classify trend velocity based on tag presence across weeks.
576 +
577 + Returns a mapping of tag -> velocity label.
578 + """
579 + if len(tags_by_week) < 2:
580 + all_tags = set()
581 + for _, tags in tags_by_week:
582 + all_tags.update(tags)
583 + return {tag: "new" for tag in all_tags}
584 +
585 + all_tags: set[str] = set()
586 + for _, tags in tags_by_week:
587 + all_tags.update(tags)
588 +
589 + velocity: dict[str, str] = {}
590 + for tag in sorted(all_tags):
591 + presence = [tag in tags for _, tags in tags_by_week]
592 + first_seen = next((i for i, p in enumerate(presence) if p), len(presence))
593 + last_seen = next(
594 + (len(presence) - 1 - i for i, p in enumerate(reversed(presence)) if p),
595 + 0,
596 + )
597 +
598 + if first_seen >= len(presence) - 1:
599 + velocity[tag] = "new"
600 + elif last_seen < len(presence) - 2:
601 + velocity[tag] = "dying"
602 + elif sum(presence[len(presence) // 2 :]) >= sum(presence[: len(presence) // 2]):
603 + velocity[tag] = "accelerating"
604 + else:
605 + velocity[tag] = "decelerating"
606 + return velocity
607 +
608 +
609 +def _synthesize_rolling_report(summaries: list[WeeklySummary]) -> str:
610 + """Synthesize a compact rolling report from up to 4 weekly summaries."""
611 + week_labels = [f"W{s.week_number}" for s in summaries]
612 + current_week = summaries[-1].week
613 +
614 + # Frontmatter
615 + lines = [
616 + "---",
617 + "title: Rolling 4-Week Context",
618 + f"updated: {current_week}",
619 + f"weeks: [{', '.join(week_labels)}]",
620 + "---",
621 + "",
622 + ]
623 +
624 + # Active Trends: synthesize from signals across weeks
625 + lines.append("## Active Trends")
626 + lines.append("")
627 + seen_signals: list[str] = []
628 + for s in summaries:
629 + if s.signal and s.signal not in seen_signals:
630 + # Truncate long signals to keep report compact
631 + signal_text = s.signal[:200] + "…" if len(s.signal) > 200 else s.signal
632 + seen_signals.append(signal_text)
633 + # Keep only most recent/relevant signals (last 4-5)
634 + for signal in seen_signals[-5:]:
635 + lines.append(f"- {signal}")
636 + lines.append("")
637 +
638 + # Trend Velocity: classify tags by presence pattern
639 + lines.append("## Trend Velocity")
640 + lines.append("")
641 + tags_by_week = [(s.week, set(s.tags)) for s in summaries]
642 + velocity = _classify_velocity(tags_by_week)
643 + for label in VELOCITY_LABELS:
644 + tags_for_label = [t for t, v in velocity.items() if v == label]
645 + if tags_for_label:
646 + lines.append(f"- **{label.capitalize()}:** {', '.join(tags_for_label)}")
647 + lines.append("")
648 +
649 + # Open Predictions: synthesize from conclusions/gaps
650 + lines.append("## Open Predictions")
651 + lines.append("")
652 + for s in summaries[-3:]:
653 + if s.gaps:
654 + gap_text = s.gaps[:150] + "…" if len(s.gaps) > 150 else s.gaps
655 + lines.append(f"- [W{s.week_number}] {gap_text}")
656 + lines.append("")
657 +
658 + # Noise Patterns: synthesize from noise fields
659 + lines.append("## Noise Patterns")
660 + lines.append("")
661 + seen_noise: list[str] = []
662 + for s in summaries:
663 + if s.noise and s.noise not in seen_noise:
664 + noise_text = s.noise[:200] + "…" if len(s.noise) > 200 else s.noise
665 + seen_noise.append(noise_text)
666 + for noise in seen_noise[-4:]:
667 + lines.append(f"- {noise}")
668 + if not seen_noise:
669 + lines.append("- No distinct noise patterns isolated in structured data.")
670 + lines.append("")
671 +
672 + return "\n".join(lines)
673 +
674 +
675 +def generate_rolling_report(content_root: Path) -> Path | None:
676 + """Generate a rolling last-4-weeks report from content/weekly/.
677 +
678 + Reads the most recent 4 weekly summaries, synthesizes them into a compact
679 + context report, and writes to content/rolling/last-month.md (overwritten
680 + each week).
681 +
682 + Returns the path written, or None if insufficient data.
683 + """
684 + summaries = _load_weekly_content(content_root)
685 + if not summaries:
686 + return None
687 +
688 + # Take the last 4 weeks
689 + recent = summaries[-4:]
690 + if not recent:
691 + return None
692 +
693 + report = _synthesize_rolling_report(recent)
694 +
695 + rolling_dir = content_root / "rolling"
696 + rolling_dir.mkdir(parents=True, exist_ok=True)
697 + output_path = rolling_dir / "last-month.md"
698 + output_path.write_text(report, encoding="utf-8")
699 + return output_path
700 +
701 +
702 def main(argv: list[str] | None = None) -> int:
703 args = parse_args(argv)
704 written = generate_rollups(args.analyzed_dir, args.content_root)
705 +
706 + if args.rolling:
707 + rolling_path = generate_rolling_report(args.content_root)
708 + if rolling_path:
709 + written.append(rolling_path)
710 + else:
711 + print("No weekly content found for rolling report.", file=sys.stderr)
712 +
713 if not written:
714 print(f"No weekly summaries found in {args.analyzed_dir}; skipping rollup generation.", file=sys.stderr)
715 return 0
tests/test_generate_rollups.py
+97
@@ -256,6 +256,103 @@ class GenerateRollupsTests(unittest.TestCase):
256 self.assertEqual(args.analyzed_dir, Path(generate_rollups.PROJECT_ROOT / "data" / "analyzed"))
257 self.assertEqual(args.content_root, Path(generate_rollups.PROJECT_ROOT / "content"))
258
259 + def test_generate_rolling_report_creates_last_month(self) -> None:
260 + with temporary_workspace() as tmpdir:
261 + base = Path(tmpdir)
262 + content_root = base / "content"
263 + weekly_dir = content_root / "weekly" / "2026"
264 + weekly_dir.mkdir(parents=True)
265 +
266 + for wnum, date in [
267 + ("21", "2026-05-21T12:00:00+00:00"),
268 + ("22", "2026-05-25T12:00:00+00:00"),
269 + ("23", "2026-06-06T12:00:00+00:00"),
270 + ("24", "2026-06-08T12:00:00+00:00"),
271 + ]:
272 + (weekly_dir / f"W{wnum}.md").write_text(
273 + make_summary(
274 + week=f"2026-W{wnum}",
275 + date=date,
276 + top_repo=f"octo/repo-{wnum}",
277 + summary=f"Summary for W{wnum}.",
278 + signal=f"Signal for W{wnum}.",
279 + noise=f"Noise for W{wnum}.",
280 + gaps=f"Gaps for W{wnum}.",
281 + conclusion=f"Conclusion for W{wnum}.",
282 + tags=("ai", "agents") if wnum in ("21", "22") else ("ai", "new-tag"),
283 + ),
284 + encoding="utf-8",
285 + )
286 +
287 + result = generate_rollups.generate_rolling_report(content_root)
288 +
289 + self.assertIsNotNone(result)
290 + self.assertEqual(result, content_root / "rolling" / "last-month.md")
291 + self.assertTrue(result.exists())
292 +
293 + report = result.read_text(encoding="utf-8")
294 + self.assertIn("title: Rolling 4-Week Context", report)
295 + self.assertIn("updated: 2026-W24", report)
296 + self.assertIn("weeks: [W21, W22, W23, W24]", report)
297 + self.assertIn("## Active Trends", report)
298 + self.assertIn("## Trend Velocity", report)
299 + self.assertIn("## Open Predictions", report)
300 + self.assertIn("## Noise Patterns", report)
301 + self.assertIn("Signal for W", report)
302 + self.assertIn("Noise for W", report)
303 + self.assertIn("[W24] Gaps for W24.", report)
304 +
305 + def test_generate_rolling_report_returns_none_when_no_content(self) -> None:
306 + with temporary_workspace() as tmpdir:
307 + base = Path(tmpdir)
308 + content_root = base / "content"
309 + content_root.mkdir(parents=True)
310 +
311 + result = generate_rollups.generate_rolling_report(content_root)
312 + self.assertIsNone(result)
313 +
314 + def test_rolling_flag_triggers_rolling_report(self) -> None:
315 + with temporary_workspace() as tmpdir:
316 + base = Path(tmpdir)
317 + analyzed_dir = base / "data" / "analyzed"
318 + content_root = base / "content"
319 + analyzed_dir.mkdir(parents=True)
320 + weekly_dir = content_root / "weekly" / "2026"
321 + weekly_dir.mkdir(parents=True)
322 +
323 + summary_text = make_summary(
324 + week="2026-W21",
325 + date="2026-05-21T12:00:00+00:00",
326 + top_repo="octo/signal-kit",
327 + summary="Test summary.",
328 + signal="Test signal.",
329 + noise="Test noise.",
330 + gaps="Test gaps.",
331 + conclusion="Test conclusion.",
332 + )
333 + (analyzed_dir / "2026-W21-summary.md").write_text(summary_text, encoding="utf-8")
334 + (weekly_dir / "W21.md").write_text(summary_text, encoding="utf-8")
335 +
336 + ret = generate_rollups.main([
337 + "--analyzed-dir", str(analyzed_dir),
338 + "--content-root", str(content_root),
339 + "--rolling",
340 + ])
341 + self.assertEqual(ret, 0)
342 + self.assertTrue((content_root / "rolling" / "last-month.md").exists())
343 +
344 + def test_velocity_classification(self) -> None:
345 + tags_by_week = [
346 + ("2026-W21", {"ai", "agents", "old-tag"}),
347 + ("2026-W22", {"ai", "agents"}),
348 + ("2026-W23", {"ai", "new-thing"}),
349 + ("2026-W24", {"ai", "new-thing"}),
350 + ]
351 + velocity = generate_rollups._classify_velocity(tags_by_week)
352 + self.assertEqual(velocity["ai"], "accelerating")
353 + self.assertEqual(velocity["old-tag"], "dying")
354 + self.assertIn(velocity["new-thing"], ("accelerating", "new"))
355 +
356
357 if __name__ == "__main__":
358 unittest.main()