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()