98
return "\n".join(lines)
99
100
101
+def _repo_link(full_name: str) -> str:
102
+ """Format a repo as a markdown link using only the repo name (after the slash)."""
103
+ repo_name = full_name.split("/")[-1]
104
+ return f"[{repo_name}](https://github.com/{full_name})"
105
+
106
+
107
+def _join_links(links: list[str]) -> str:
108
+ """Join a list of markdown links into a readable phrase."""
109
+ if len(links) == 1:
110
+ return links[0]
111
+ if len(links) == 2:
112
+ return f"{links[0]} and {links[1]}"
113
+ return f"{', '.join(links[:-1])}, and {links[-1]}"
114
+
115
+
116
+def _format_unpublicized_narrative(items: list[dict]) -> str:
117
+ """Generate narrative paragraph(s) for dev activity without press coverage."""
118
+ if not items:
119
+ return ""
120
+
121
+ # Sort topics by total stars, cap at 6
122
+ sorted_items = sorted(
123
+ items,
124
+ key=lambda x: sum(r.get("stars", 0) for r in x.get("github_repos", [])),
125
+ reverse=True,
126
+ )[:6]
127
+
128
+ topic_parts: list[tuple[str, list[str]]] = []
129
+ for item in sorted_items:
130
+ topic = item.get("topic", "unknown")
131
+ repos = sorted(
132
+ item.get("github_repos", []),
133
+ key=lambda r: r.get("stars", 0),
134
+ reverse=True,
135
+ )
136
+ links = [_repo_link(r["full_name"]) for r in repos[:3] if r.get("full_name")]
137
+ if links:
138
+ topic_parts.append((topic, links))
139
+
140
+ if not topic_parts:
141
+ return ""
142
+
143
+ # First paragraph: intro + first three topics
144
+ first_batch = topic_parts[:3]
145
+ fragments = [
146
+ f"{topic} saw activity with {_join_links(links)}"
147
+ for topic, links in first_batch
148
+ ]
149
+ para1 = (
150
+ "Developer activity this week shows momentum in areas the tech press isn't covering. "
151
+ + "; ".join(fragments)
152
+ + "."
153
+ )
154
+
155
+ paragraphs = [para1]
156
+
157
+ # Second paragraph for remaining topics
158
+ if len(topic_parts) > 3:
159
+ second_batch = topic_parts[3:]
160
+ fragments2 = [
161
+ f"{topic} with {_join_links(links)}" for topic, links in second_batch
162
+ ]
163
+ paragraphs.append("Additional activity surfaced in " + ", ".join(fragments2) + ".")
164
+
165
+ paragraphs.append(
166
+ "These gaps suggest that foundational developer tooling — the infrastructure "
167
+ "that powers daily workflows — grows through community word-of-mouth rather than press cycles."
168
+ )
169
+
170
+ return "\n\n".join(paragraphs)
171
+
172
+
173
+def _format_uncovered_narrative(items: list[dict]) -> str:
174
+ """Generate a narrative paragraph for tech trends without dev activity."""
175
+ if not items:
176
+ return ""
177
+
178
+ display = items[:5]
179
+
180
+ topic_names = [item.get("topic", "unknown") for item in display]
181
+
182
+ # Collect up to two article links across all topics
183
+ article_links: list[str] = []
184
+ for item in display:
185
+ for a in item.get("techcrunch_articles", [])[:1]:
186
+ title = a.get("title", "article")
187
+ url = a.get("url", "")
188
+ if url:
189
+ article_links.append(f"[{title}]({url})")
190
+ if len(article_links) >= 2:
191
+ break
192
+
193
+ if len(topic_names) == 1:
194
+ topics_str = topic_names[0]
195
+ elif len(topic_names) == 2:
196
+ topics_str = f"{topic_names[0]} and {topic_names[1]}"
197
+ else:
198
+ topics_str = f"{', '.join(topic_names[:-1])}, and {topic_names[-1]}"
199
+
200
+ if article_links:
201
+ if len(article_links) == 1:
202
+ article_str = f"Articles like {article_links[0]} generated buzz"
203
+ else:
204
+ article_str = (
205
+ f"Articles like {article_links[0]} and {article_links[1]} generated buzz"
206
+ )
207
+ else:
208
+ article_str = "Press articles generated buzz"
209
+
210
+ return (
211
+ f"TechCrunch heavily covered {topics_str} this week, but GitHub shows minimal "
212
+ f"matching developer activity. {article_str}, yet no significant new repositories "
213
+ f"emerged in these spaces — suggesting these are still in the narrative or "
214
+ f"announcement phase rather than implementation."
215
+ )
216
+
217
+
218
def format_divergences(divergences: dict, *, reader_mode: bool = False) -> str:
219
"""Format divergences section into markdown.
220
221
Args:
222
divergences: Divergence data dict.
106
- reader_mode: When True, replaces the AI instruction block with a
107
- reader-friendly conclusion sentence.
223
+ reader_mode: When True, renders narrative paragraphs with inline repo/article
224
+ links instead of raw bullet lists. When False (AI prompt mode),
225
+ the original bullet-list format is preserved unchanged.
226
"""
227
if not divergences:
228
return ""
235
236
lines = ["\n### Divergence Analysis\n"]
237
120
- # In reader mode, cap divergence lists to keep output concise
121
- max_items = 10 if reader_mode else None
122
-
123
- if uncovered:
124
- lines.append("#### 🔍 Tech Trends Without Dev Activity")
125
- lines.append("Topics heavily covered by TechCrunch with no matching GitHub repos:\n")
126
- display_uncovered = uncovered[:max_items] if max_items else uncovered
127
- for item in display_uncovered:
128
- topic = item.get("topic", "unknown")
129
- articles = item.get("techcrunch_articles", [])
130
- article_refs = ", ".join(
131
- f"[{a.get('title', 'article')}]({a.get('url', '')})"
132
- for a in articles[:3]
133
- )
134
- lines.append(f"- **{topic}**: {article_refs}")
135
- if max_items and len(uncovered) > max_items:
136
- lines.append(f"- …and {len(uncovered) - max_items} more tech trends without dev activity")
137
- lines.append("")
138
-
139
- if unpublicized:
140
- lines.append("#### 🚀 Dev Activity Without Press Coverage")
141
- lines.append("GitHub repos/trends with no matching TechCrunch coverage:\n")
142
- display_unpub = unpublicized[:max_items] if max_items else unpublicized
143
- for item in display_unpub:
144
- topic = item.get("topic", "unknown")
145
- repos = item.get("github_repos", [])
146
- repo_refs = ", ".join(
147
- f"{r.get('full_name', '?')} (⭐{r.get('stars', 0)})"
148
- for r in repos[:3]
149
- )
150
- lines.append(f"- **{topic}**: {repo_refs}")
151
- if max_items and len(unpublicized) > max_items:
152
- lines.append(f"- …and {len(unpublicized) - max_items} more dev topics without press coverage")
153
- lines.append("")
154
-
238
if reader_mode:
156
- lines.append(
157
- "These divergences highlight gaps between what the tech industry is reporting "
158
- "and what developers are actually building."
159
- )
239
+ # Narrative mode: flowing prose with inline links, no raw data dumps
240
+ if uncovered:
241
+ lines.append("#### 🔍 Tech Trends Without Dev Activity\n")
242
+ lines.append(_format_uncovered_narrative(uncovered))
243
+ lines.append("")
244
+
245
+ if unpublicized:
246
+ lines.append("#### 🚀 Dev Activity Without Press Coverage\n")
247
+ lines.append(_format_unpublicized_narrative(unpublicized))
248
+ lines.append("")
249
else:
250
+ # AI prompt mode: full raw data for model consumption — keep unchanged
251
+ if uncovered:
252
+ lines.append("#### 🔍 Tech Trends Without Dev Activity")
253
+ lines.append("Topics heavily covered by TechCrunch with no matching GitHub repos:\n")
254
+ for item in uncovered:
255
+ topic = item.get("topic", "unknown")
256
+ articles = item.get("techcrunch_articles", [])
257
+ article_refs = ", ".join(
258
+ f"[{a.get('title', 'article')}]({a.get('url', '')})"
259
+ for a in articles[:3]
260
+ )
261
+ lines.append(f"- **{topic}**: {article_refs}")
262
+ lines.append("")
263
+
264
+ if unpublicized:
265
+ lines.append("#### 🚀 Dev Activity Without Press Coverage")
266
+ lines.append("GitHub repos/trends with no matching TechCrunch coverage:\n")
267
+ for item in unpublicized:
268
+ topic = item.get("topic", "unknown")
269
+ repos = item.get("github_repos", [])
270
+ repo_refs = ", ".join(
271
+ f"{r.get('full_name', '?')} (⭐{r.get('stars', 0)})"
272
+ for r in repos[:3]
273
+ )
274
+ lines.append(f"- **{topic}**: {repo_refs}")
275
+ lines.append("")
276
+
277
lines.append("#### Divergence Instructions")
278
lines.append("Use divergences to identify:")
279
lines.append("- 🔮 Where industry is moving but devs haven't caught up")