fix(seo): close pipeline gaps in meta descriptions and cross-linking (#522)

* fix(seo): close pipeline gaps in meta descriptions and cross-linking - Add summary field to yearly frontmatter derived from narrative - Add 155-char max constraint for weekly summary in analysis prompt - Add max 70-char guidance for weekly title in analysis prompt - Add cross-links: monthly→yearly and monthly→weekly navigation - Add cross-links: yearly→monthly navigation in rendered pages - Monthly pages already have summary populated (verified) Closes #521 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address all 5 Copilot review comments on PR #522 - Fix _extract_summary regex to handle .!? terminators with whitespace lookahead and strip leading whitespace - Rename unused 'month' param to '_month' in _build_monthly_crosslinks - Move summary constraint from sub-bullet to inline rule 13 text in analyze-weekly.md prompt - Move nav-link injection into build_yearly_narrative_pages so both render_yearly_page and generate_rollups pipelines include monthly navigation links - Add _extract_summary tests covering sentence terminators, truncation, word boundaries, whitespace, and frontmatter integration 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 17, 2026 at 00:53 UTC 2a00eb196b22a89d7709a2bc570cdcf6477006af
4 files changed +125 -6
prompts/analyze-weekly.md
+3 -3
@@ -127,7 +127,7 @@ Be critical, selective, and opinionated.
127 - `quality_score`
128 - `summary`
129 Optional: `predictions`
130 -6. `title` must be a punchy 5-12 word journalistic headline that captures the week's dominant themes. Never use generic week/year labels such as `Week NN, YYYY Analysis` or `Week NN, YYYY`.
130 +6. `title` must be a punchy 5-12 word journalistic headline (max 70 characters) that captures the week's dominant themes. Never use generic week/year labels such as `Week NN, YYYY Analysis` or `Week NN, YYYY`.
131 - Good: `Agent Skills, Exploit Churn, and the Language Nobody Asked For`
132 - Good: `The Week Local Models Went Mainstream`
133 - Good: `MCP Eats the Middleware Layer While VCs Look Elsewhere`
@@ -137,7 +137,7 @@ Be critical, selective, and opinionated.
137 10. `repos_featured` should equal the total number of repos considered in the weekly editorial pass.
138 11. `stars_tracked` should equal the total stars across those repos.
139 12. `top_repo` should be the repo that best anchors the editorial narrative, not automatically the most-starred repo.
140 -13. `quality_score` must be an honest 0-100 self-assessment; publishable work is `>= 60`.
140 +13. `quality_score` must be an honest 0-100 self-assessment; publishable work is `>= 60`. The `summary` field must be ≤155 characters, a complete sentence crafted as the meta description for search engines and social sharing. Do not let it exceed 155 characters.
141 14. If you include `predictions`, each entry must be `{repo, claim_type, direction, confidence}` with `claim_type` in `signal|noise|gap`, `direction` in `up|flat|down`, and `confidence` from `0` to `1`.
142 15. Include all required sections in this exact order:
143
@@ -199,7 +199,7 @@ repos_featured: 0
199 stars_tracked: 0
200 top_repo: "owner/repo"
201 quality_score: 0
202 -summary: "One-sentence editorial thesis."
202 +summary: "One-sentence editorial thesis (max 155 chars, used as meta description)."
203 predictions:
204 - repo: owner/repo
205 claim_type: signal
scripts/generate_rollups.py
+11 -1
@@ -355,6 +355,15 @@ def monthly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[s
355 }
356
357
358 +def _build_monthly_crosslinks(year: int, _month: int, items: list[WeeklySummary]) -> str:
359 + """Build navigation cross-links for a monthly page."""
360 + links = []
361 + links.append(f"[{year} Year in Review](/yearly/{year}/)")
362 + for item in items:
363 + links.append(f"[{item.week_title}]({item.week_link})")
364 + return f"*Part of {links[0]}* · Weekly: {' · '.join(links[1:])}\n"
365 +
366 +
367 def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path, analyzed_dir: Path) -> list[RollupPage]:
368 grouped: dict[tuple[int, int], list[WeeklySummary]] = defaultdict(list)
369 for summary in summaries:
@@ -368,7 +377,8 @@ def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path, anal
377
378 synthesis = ensure_month_synthesis(items, analyzed_dir)
379
371 - synthesis_text = synthesis.narrative
380 + crosslinks = _build_monthly_crosslinks(year, month, items)
381 + synthesis_text = crosslinks + "\n" + synthesis.narrative
382 if synthesis.trend_arc:
383 synthesis_text += f"\n\n### Trend Arc\n\n{synthesis.trend_arc}"
384 page_entries["Month Synthesis"].append(
scripts/generate_yearly_narrative.py
+35 -2
@@ -680,6 +680,25 @@ def generate_yearly_title(year: int, arcs: dict[str, list[str]]) -> str:
680 return title[:70]
681
682
683 +def _extract_summary(narrative: str, max_length: int = 155) -> str:
684 + """Extract a ≤155-character summary from the narrative's first sentence."""
685 + narrative = narrative.strip()
686 + # Take first sentence (up to first . ! or ? followed by whitespace or end)
687 + first_sentence_match = re.match(r"(.+?[.!?])(?:\s|$)", narrative)
688 + if first_sentence_match:
689 + sentence = first_sentence_match.group(1).strip()
690 + if len(sentence) <= max_length:
691 + return sentence
692 + # Truncate at last word boundary within limit
693 + truncated = sentence[:max_length - 1].rsplit(" ", 1)[0]
694 + return truncated.rstrip(".,;:") + "…"
695 + # Fallback: truncate narrative at word boundary
696 + if len(narrative) <= max_length:
697 + return narrative.strip()
698 + truncated = narrative[:max_length - 1].rsplit(" ", 1)[0]
699 + return truncated.rstrip(".,;:") + "…"
700 +
701 +
702 def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None = None) -> list[YearlyNarrativePage]:
703 grouped: dict[int, list[MonthSnapshot]] = {}
704 for snapshot in load_month_snapshots(content_root, years):
@@ -691,6 +710,19 @@ def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None
710 narrative = synthesize_year(ordered)
711 arcs = {family.key: detect_family_arc(ordered, family) for family in TREND_FAMILIES}
712 title = generate_yearly_title(year, arcs)
713 + summary = _extract_summary(narrative)
714 + month_slugs = [month.month_slug for month in ordered]
715 + nav_links = []
716 + for slug in month_slugs:
717 + parts = slug.split("-")
718 + if len(parts) == 2:
719 + year_str, month_str = parts
720 + month_num = int(month_str)
721 + month_name = MONTH_NAMES.get(month_num, month_str)
722 + nav_links.append(f"[{month_name}](/monthly/{year_str}/{month_str}/)")
723 + nav_prefix = ""
724 + if nav_links:
725 + nav_prefix = f"**Monthly reports:** {' · '.join(nav_links)}\n\n"
726 pages.append(
727 YearlyNarrativePage(
728 year=year,
@@ -700,10 +732,11 @@ def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None
732 "date": ordered[-1].date,
733 "year": year,
734 "categories": ["yearly"],
703 - "months_covered": [month.month_slug for month in ordered],
735 + "months_covered": month_slugs,
736 "format": "narrative",
737 + "summary": summary,
738 },
706 - narrative=narrative,
739 + narrative=nav_prefix + narrative,
740 )
741 )
742 return pages
tests/test_generate_rollups.py
+76
@@ -490,6 +490,82 @@ The strongest thread was a shift from raw capability talk toward packaging, trus
490 self.assertEqual(velocity["old-tag"], "dying")
491 self.assertIn(velocity["new-thing"], ("accelerating", "new"))
492
493 + def test_extract_summary_in_yearly_frontmatter(self) -> None:
494 + """Yearly pages must include a summary field in frontmatter."""
495 + with temporary_workspace() as tmpdir:
496 + base = Path(tmpdir)
497 + analyzed_dir = base / "data" / "analyzed"
498 + content_root = base / "content"
499 + analyzed_dir.mkdir(parents=True)
500 +
501 + (analyzed_dir / "2026-W21-summary.md").write_text(
502 + make_summary(
503 + week="2026-W21",
504 + date="2026-05-18T12:07:20+00:00",
505 + top_repo="octo/signal-kit",
506 + summary="Practical agent tooling led the week.",
507 + signal="Teams preferred operational automation over generic hype.",
508 + noise="Exploit-heavy projects still added editorial noise.",
509 + gaps="Reliable momentum data remained missing.",
510 + conclusion="The strongest projects made automation safer to adopt.",
511 + ),
512 + encoding="utf-8",
513 + )
514 +
515 + generate_rollups.generate_rollups(analyzed_dir, content_root)
516 + yearly_path = content_root / "yearly" / "2026.md"
517 + yearly = yearly_path.read_text(encoding="utf-8")
518 + self.assertIn("summary:", yearly)
519 + # Extract summary value and verify length constraint
520 + for line in yearly.splitlines():
521 + if line.strip().startswith("summary:"):
522 + summary_value = line.split(":", 1)[1].strip().strip('"')
523 + self.assertLessEqual(len(summary_value), 155)
524 + break
525 + else:
526 + self.fail("summary field not found in yearly frontmatter")
527 +
528 +
529 +class ExtractSummaryTests(unittest.TestCase):
530 + def test_short_sentence_returned_as_is(self) -> None:
531 + result = generate_yearly_narrative._extract_summary("Short sentence.")
532 + self.assertEqual(result, "Short sentence.")
533 +
534 + def test_exclamation_mark_terminates_sentence(self) -> None:
535 + result = generate_yearly_narrative._extract_summary("Wow! More text follows here.")
536 + self.assertEqual(result, "Wow!")
537 +
538 + def test_question_mark_terminates_sentence(self) -> None:
539 + result = generate_yearly_narrative._extract_summary("Why not? The rest is irrelevant.")
540 + self.assertEqual(result, "Why not?")
541 +
542 + def test_truncation_respects_max_length(self) -> None:
543 + long = "A" * 200 + "."
544 + result = generate_yearly_narrative._extract_summary(long, max_length=50)
545 + self.assertLessEqual(len(result), 50)
546 + self.assertTrue(result.endswith("…"))
547 +
548 + def test_truncation_at_word_boundary(self) -> None:
549 + sentence = "This is a moderately long sentence that should be truncated at a word boundary when it exceeds the maximum allowed length for meta descriptions."
550 + result = generate_yearly_narrative._extract_summary(sentence, max_length=60)
551 + self.assertLessEqual(len(result), 60)
552 + self.assertTrue(result.endswith("…"))
553 + self.assertFalse(result[-2].isspace())
554 +
555 + def test_leading_whitespace_stripped(self) -> None:
556 + result = generate_yearly_narrative._extract_summary(" Leading spaces. More text.")
557 + self.assertEqual(result, "Leading spaces.")
558 +
559 + def test_fallback_when_no_sentence_terminator(self) -> None:
560 + result = generate_yearly_narrative._extract_summary("No punctuation at all")
561 + self.assertEqual(result, "No punctuation at all")
562 +
563 + def test_fallback_truncation_for_long_text_without_terminator(self) -> None:
564 + long = "word " * 50
565 + result = generate_yearly_narrative._extract_summary(long, max_length=30)
566 + self.assertLessEqual(len(result), 30)
567 + self.assertTrue(result.endswith("…"))
568 +
569
570 if __name__ == "__main__":
571 unittest.main()