| 1 | """Hacker News comments must survive the per-source top-comment floor. |
| 2 | |
| 3 | HN comments arrive as ``{author, text, points}`` while every downstream reader |
| 4 | keys on ``score``/``excerpt``. Before the fix, ``_normalize_hackernews`` stored |
| 5 | them raw, so ``render._top_comments_list`` evaluated ``(c.get("score") or 0) >= 5`` |
| 6 | against a key that was never present and rejected the entire source. |
| 7 | """ |
| 8 | |
| 9 | from unittest.mock import patch |
| 10 | |
| 11 | from lib import hackernews, normalize, render, schema |
| 12 | |
| 13 | FROM_DATE = "2026-06-26" |
| 14 | TO_DATE = "2026-07-26" |
| 15 | |
| 16 | |
| 17 | def _hn_item(): |
| 18 | return { |
| 19 | "id": "42", |
| 20 | "title": "Show HN: a thing", |
| 21 | "url": "https://example.test/thing", |
| 22 | "hn_url": "https://news.ycombinator.com/item?id=42", |
| 23 | "author": "pg", |
| 24 | "date": "2026-07-20", |
| 25 | "engagement": {"points": 420, "comments": 2}, |
| 26 | "top_comments": [ |
| 27 | { |
| 28 | "author": "alice", |
| 29 | "text": "The single clearest explanation I have read.", |
| 30 | "points": 93, |
| 31 | }, |
| 32 | { |
| 33 | "author": "bob", |
| 34 | "text": "Counterpoint: the benchmark skips the hard cases.", |
| 35 | "points": None, |
| 36 | }, |
| 37 | ], |
| 38 | } |
| 39 | |
| 40 | |
| 41 | def _reddit_item(): |
| 42 | return { |
| 43 | "id": "r1", |
| 44 | "title": "a reddit thread", |
| 45 | "url": "https://reddit.test/r1", |
| 46 | "author": "carol", |
| 47 | "date": "2026-07-20", |
| 48 | "engagement": {"upvotes": 300, "comments": 2}, |
| 49 | "top_comments": [ |
| 50 | { |
| 51 | "author": "alice", |
| 52 | "excerpt": "The single clearest explanation I have read.", |
| 53 | "score": 93, |
| 54 | }, |
| 55 | { |
| 56 | "author": "bob", |
| 57 | "excerpt": "Counterpoint: the benchmark skips the hard cases.", |
| 58 | "score": 51, |
| 59 | }, |
| 60 | ], |
| 61 | } |
| 62 | |
| 63 | |
| 64 | def test_hn_comments_are_remapped_to_the_shared_shape(): |
| 65 | item = normalize._normalize_hackernews( |
| 66 | "hackernews", _hn_item(), 0, FROM_DATE, TO_DATE |
| 67 | ) |
| 68 | comments = item.metadata["top_comments"] |
| 69 | assert comments, "HN comments should survive normalisation" |
| 70 | for comment in comments: |
| 71 | assert "score" in comment, ( |
| 72 | f"expected the shared score key, got {sorted(comment)}" |
| 73 | ) |
| 74 | assert "excerpt" in comment, ( |
| 75 | f"expected the shared excerpt key, got {sorted(comment)}" |
| 76 | ) |
| 77 | assert comments[0]["score"] == 93, "points must carry through as score" |
| 78 | |
| 79 | |
| 80 | def test_hn_comments_clear_the_per_source_floor(): |
| 81 | item = normalize._normalize_hackernews( |
| 82 | "hackernews", _hn_item(), 0, FROM_DATE, TO_DATE |
| 83 | ) |
| 84 | assert render._top_comments_list(item), ( |
| 85 | "HN comments must not be filtered out by a floor keyed on a field the " |
| 86 | "source never populated" |
| 87 | ) |
| 88 | |
| 89 | |
| 90 | def test_hn_floor_is_zero_because_hn_has_no_per_comment_points(): |
| 91 | """The Algolia items endpoint returns points=null for every comment child. |
| 92 | |
| 93 | Any positive threshold therefore rejects the whole source rather than |
| 94 | filtering it, so this constant is load-bearing rather than a tuning knob. |
| 95 | """ |
| 96 | assert render._TOP_COMMENT_MIN_SCORE["hackernews"] == 0 |
| 97 | |
| 98 | |
| 99 | def test_reddit_control_is_unchanged(): |
| 100 | item = normalize._normalize_reddit("reddit", _reddit_item(), 0, FROM_DATE, TO_DATE) |
| 101 | assert len(render._top_comments_list(item)) == 2 |
| 102 | |
| 103 | |
| 104 | def _candidate(source, item): |
| 105 | return schema.Candidate( |
| 106 | candidate_id="c1", |
| 107 | item_id=item.item_id, |
| 108 | source=source, |
| 109 | title=item.title, |
| 110 | url=item.url, |
| 111 | snippet="", |
| 112 | subquery_labels=[], |
| 113 | native_ranks={}, |
| 114 | local_relevance=0.9, |
| 115 | freshness=1, |
| 116 | engagement=100, |
| 117 | source_quality=0.9, |
| 118 | rrf_score=1.0, |
| 119 | source_items=[item], |
| 120 | final_score=50.0, |
| 121 | explanation="llm-rerank", |
| 122 | ) |
| 123 | |
| 124 | |
| 125 | def _report(item, candidate): |
| 126 | return schema.Report( |
| 127 | topic="HN comments", |
| 128 | range_from=FROM_DATE, |
| 129 | range_to=TO_DATE, |
| 130 | generated_at=f"{TO_DATE}T00:00:00+00:00", |
| 131 | provider_runtime=schema.ProviderRuntime( |
| 132 | reasoning_provider="local", |
| 133 | planner_model="local", |
| 134 | rerank_model="local", |
| 135 | ), |
| 136 | query_plan=schema.QueryPlan( |
| 137 | intent="general", |
| 138 | freshness_mode="balanced_recent", |
| 139 | cluster_mode="story", |
| 140 | raw_topic="HN comments", |
| 141 | subqueries=[ |
| 142 | schema.SubQuery( |
| 143 | label="primary", |
| 144 | search_query="HN comments", |
| 145 | ranking_query="What are the top HN comments?", |
| 146 | sources=["hackernews"], |
| 147 | ) |
| 148 | ], |
| 149 | source_weights={"hackernews": 1.0}, |
| 150 | ), |
| 151 | clusters=[], |
| 152 | ranked_candidates=[candidate], |
| 153 | items_by_source={"hackernews": [item]}, |
| 154 | errors_by_source={}, |
| 155 | ) |
| 156 | |
| 157 | |
| 158 | def _render_vote_paths(points): |
| 159 | raw = _hn_item() |
| 160 | with patch("lib.hackernews.http.request") as request: |
| 161 | request.return_value = { |
| 162 | "children": [ |
| 163 | { |
| 164 | "author": "alice", |
| 165 | "text": raw["top_comments"][0]["text"], |
| 166 | "points": 93, |
| 167 | }, |
| 168 | { |
| 169 | "author": "bob", |
| 170 | "text": raw["top_comments"][1]["text"], |
| 171 | "points": points, |
| 172 | }, |
| 173 | ] |
| 174 | } |
| 175 | raw["top_comments"] = hackernews._fetch_item_comments("42")["comments"] |
| 176 | hn = normalize._normalize_hackernews( |
| 177 | "hackernews", raw, 0, FROM_DATE, TO_DATE |
| 178 | ) |
| 179 | candidate = _candidate("hackernews", hn) |
| 180 | report = _report(hn, candidate) |
| 181 | return hn.metadata["top_comments"][1]["score"], { |
| 182 | "full": render.render_full(report), |
| 183 | "candidate": "\n".join(render._render_candidate(candidate, prefix="1.")), |
| 184 | "top_comments": "\n".join(render._render_top_comments(report)), |
| 185 | } |
| 186 | |
| 187 | |
| 188 | def test_absent_hn_vote_is_omitted_across_comment_renderers(): |
| 189 | """A missing vote must not display a fabricated numeric measurement.""" |
| 190 | normalized_score, rendered_paths = _render_vote_paths(None) |
| 191 | assert normalized_score is None |
| 192 | for path, rendered in rendered_paths.items(): |
| 193 | assert "(0 points)" not in rendered, f"{path}:\n{rendered}" |
| 194 | assert "(93 points)" in rendered, f"{path} dropped a real vote count" |
| 195 | |
| 196 | |
| 197 | def test_explicit_zero_hn_vote_is_rendered_across_comment_renderers(): |
| 198 | """A measured zero remains visible in every zero-admitting comment path.""" |
| 199 | normalized_score, rendered_paths = _render_vote_paths(0) |
| 200 | assert normalized_score == 0 |
| 201 | for path, rendered in rendered_paths.items(): |
| 202 | assert "(0 points)" in rendered, f"{path} hid an explicit numeric zero" |
| 203 | |
| 204 | |
| 205 | def test_best_takes_uses_normalized_hn_comment_excerpt(): |
| 206 | """Best Takes should display HN comment text, not only the story title.""" |
| 207 | first_raw = _hn_item() |
| 208 | first_raw["title"] = "Show HN: a deliberately long story title for testing" |
| 209 | first_raw["top_comments"] = [ |
| 210 | {"author": "alice", "text": "A sharp HN take.", "points": None} |
| 211 | ] |
| 212 | second_raw = _hn_item() |
| 213 | second_raw["id"] = "43" |
| 214 | second_raw["title"] = "Ask HN: another deliberately long story title for testing" |
| 215 | second_raw["top_comments"] = [ |
| 216 | {"author": "bob", "text": "Another good take.", "points": None} |
| 217 | ] |
| 218 | first = _candidate( |
| 219 | "hackernews", |
| 220 | normalize._normalize_hackernews( |
| 221 | "hackernews", first_raw, 0, FROM_DATE, TO_DATE |
| 222 | ), |
| 223 | ) |
| 224 | second = _candidate( |
| 225 | "hackernews", |
| 226 | normalize._normalize_hackernews( |
| 227 | "hackernews", second_raw, 1, FROM_DATE, TO_DATE |
| 228 | ), |
| 229 | ) |
| 230 | first.fun_score = 80.0 |
| 231 | second.fun_score = 80.0 |
| 232 | |
| 233 | rendered = "\n".join( |
| 234 | render._render_best_takes([first, second], threshold=70.0, vote_weight=0.0) |
| 235 | ) |
| 236 | assert "A sharp HN take." in rendered |
| 237 | assert "Another good take." in rendered |
| 238 | |
| 239 | |
| 240 | def test_best_takes_uses_hn_comment_longer_than_story_title(): |
| 241 | """HN comment excerpts remain the take even when longer than the title.""" |
| 242 | first_raw = _hn_item() |
| 243 | first_raw["title"] = "Short story" |
| 244 | first_raw["top_comments"] = [ |
| 245 | { |
| 246 | "author": "alice", |
| 247 | "text": "This longer Hacker News comment is the actual sharp take.", |
| 248 | "points": None, |
| 249 | } |
| 250 | ] |
| 251 | second_raw = _hn_item() |
| 252 | second_raw["id"] = "43" |
| 253 | second_raw["top_comments"] = [ |
| 254 | {"author": "bob", "text": "Another good take.", "points": None} |
| 255 | ] |
| 256 | first = _candidate( |
| 257 | "hackernews", |
| 258 | normalize._normalize_hackernews( |
| 259 | "hackernews", first_raw, 0, FROM_DATE, TO_DATE |
| 260 | ), |
| 261 | ) |
| 262 | second = _candidate( |
| 263 | "hackernews", |
| 264 | normalize._normalize_hackernews( |
| 265 | "hackernews", second_raw, 1, FROM_DATE, TO_DATE |
| 266 | ), |
| 267 | ) |
| 268 | first.fun_score = 80.0 |
| 269 | second.fun_score = 80.0 |
| 270 | |
| 271 | rendered = "\n".join( |
| 272 | render._render_best_takes([first, second], threshold=70.0, vote_weight=0.0) |
| 273 | ) |
| 274 | assert "This longer Hacker News comment is the actual sharp take." in rendered |
| 275 | assert '"Short story"' not in rendered |
| 276 | |
| 277 | |
| 278 | def test_best_takes_uses_hn_excerpt_retained_by_fused_candidate(): |
| 279 | """A non-HN representative must not hide its retained HN comment.""" |
| 280 | reddit = normalize._normalize_reddit( |
| 281 | "reddit", _reddit_item(), 0, FROM_DATE, TO_DATE |
| 282 | ) |
| 283 | hn_raw = _hn_item() |
| 284 | hn_raw["top_comments"] = [ |
| 285 | {"author": "alice", "text": "The retained HN take.", "points": 93} |
| 286 | ] |
| 287 | hn = normalize._normalize_hackernews( |
| 288 | "hackernews", hn_raw, 0, FROM_DATE, TO_DATE |
| 289 | ) |
| 290 | fused = _candidate("reddit", reddit) |
| 291 | fused.source_items = [reddit, hn] |
| 292 | assert fused.source != "hackernews" |
| 293 | control_raw = _reddit_item() |
| 294 | control_raw["id"] = "r2" |
| 295 | control_raw["title"] = "a separate reddit thread" |
| 296 | control = _candidate( |
| 297 | "reddit", |
| 298 | normalize._normalize_reddit( |
| 299 | "reddit", control_raw, 1, FROM_DATE, TO_DATE |
| 300 | ), |
| 301 | ) |
| 302 | fused.fun_score = 80.0 |
| 303 | control.fun_score = 80.0 |
| 304 | |
| 305 | with patch( |
| 306 | "lib.render.signals.normalized_comment_vote", |
| 307 | wraps=render.signals.normalized_comment_vote, |
| 308 | ) as normalized_vote: |
| 309 | top_comments = "\n".join( |
| 310 | render._render_top_comments( |
| 311 | _report(reddit, fused), |
| 312 | candidates=[fused, control], |
| 313 | ) |
| 314 | ) |
| 315 | rendered = "\n".join( |
| 316 | render._render_best_takes([fused, control], threshold=70.0, vote_weight=0.0) |
| 317 | ) |
| 318 | assert "The retained HN take." in rendered |
| 319 | fused_line = next( |
| 320 | line for line in rendered.splitlines() if "The retained HN take." in line |
| 321 | ) |
| 322 | assert "-- Hacker News " in fused_line |
| 323 | assert "Reddit" not in fused_line |
| 324 | assert "r/" not in fused_line |
| 325 | retained_line = next( |
| 326 | line for line in top_comments.splitlines() if "The retained HN take." in line |
| 327 | ) |
| 328 | assert "— alice (93 points)" in retained_line |
| 329 | assert "upvotes" not in retained_line |
| 330 | assert "u/alice" not in retained_line |
| 331 | assert any( |
| 332 | call.args == ("hackernews", 93) for call in normalized_vote.call_args_list |
| 333 | ) |
| 334 |