返回 last30days-skill
test_internals_v3.py
根目录 / tests / test_internals_v3.py
1 """Unit tests for untested internal functions across rerank, render, planner, and signals.
2
3 These pin the correct behavior of core building blocks that higher-level
4 tests exercise transitively but don't assert on directly. A regression in
5 any of these functions would silently degrade output quality.
6 """
7
8 import unittest
9
10 from lib import planner, rerank, render, signals, schema
11
12
13 def _item(source: str = "reddit", **kwargs) -> schema.SourceItem:
14 defaults = dict(
15 item_id="t1", source=source, title="Test Title", body="Test body",
16 url="https://example.com", engagement={}, metadata={},
17 )
18 defaults.update(kwargs)
19 return schema.SourceItem(**defaults)
20
21
22 def _candidate(source: str = "reddit", **kwargs) -> schema.Candidate:
23 defaults = dict(
24 candidate_id="c1", item_id="t1", source=source, title="Test",
25 url="https://example.com", snippet="snippet", subquery_labels=["primary"],
26 native_ranks={"primary": 1}, local_relevance=0.5, freshness=50,
27 engagement=50, source_quality=0.7, rrf_score=0.01, sources=[source],
28 source_items=[],
29 )
30 defaults.update(kwargs)
31 return schema.Candidate(**defaults)
32
33 # ---------------------------------------------------------------------------
34 # rerank._fallback_tuple
35 # ---------------------------------------------------------------------------
36
37
38 class TestFallbackTuple(unittest.TestCase):
39
40 def test_returns_score_and_explanation(self):
41 c = _candidate(local_relevance=0.8, freshness=80, source_quality=0.7)
42 score, explanation = rerank._fallback_tuple(c)
43 self.assertIsInstance(score, float)
44 self.assertEqual(explanation, "fallback-local-score")
45
46 def test_score_clamped_to_0_100(self):
47 c = _candidate(local_relevance=2.0, freshness=200, source_quality=2.0)
48 score, _ = rerank._fallback_tuple(c)
49 self.assertLessEqual(score, 100.0)
50 self.assertGreaterEqual(score, 0.0)
51
52 def test_higher_relevance_gives_higher_score(self):
53 high = _candidate(local_relevance=0.9, freshness=50, source_quality=0.7)
54 low = _candidate(local_relevance=0.1, freshness=50, source_quality=0.7)
55 self.assertGreater(rerank._fallback_tuple(high)[0], rerank._fallback_tuple(low)[0])
56
57 # ---------------------------------------------------------------------------
58 # rerank._normalized_rrf
59 # ---------------------------------------------------------------------------
60
61
62 class TestNormalizedRrf(unittest.TestCase):
63
64 def test_zero_input(self):
65 self.assertAlmostEqual(rerank._normalized_rrf(0.0), 0.0)
66
67 def test_positive_input(self):
68 result = rerank._normalized_rrf(0.04)
69 self.assertGreater(result, 0.0)
70 self.assertLessEqual(result, 100.0)
71
72 def test_clamped_at_100(self):
73 result = rerank._normalized_rrf(1.0)
74 self.assertLessEqual(result, 100.0)
75
76 # ---------------------------------------------------------------------------
77 # render._assess_data_freshness
78 # ---------------------------------------------------------------------------
79
80
81 class TestAssessDataFreshness(unittest.TestCase):
82
83 def _report(self, items_by_source: dict) -> schema.Report:
84 return schema.Report(
85 topic="test", range_from="2026-02-15", range_to="2026-03-17",
86 generated_at="2026-03-17T00:00:00Z",
87 provider_runtime=schema.ProviderRuntime(
88 reasoning_provider="test", planner_model="test", rerank_model="test",
89 ),
90 query_plan=schema.QueryPlan(
91 intent="comparison", freshness_mode="balanced_recent",
92 cluster_mode="debate", raw_topic="test", subqueries=[],
93 source_weights={},
94 ),
95 clusters=[], ranked_candidates=[],
96 items_by_source=items_by_source, errors_by_source={},
97 )
98
99 def test_no_items_returns_warning(self):
100 report = self._report({})
101 result = render._assess_data_freshness(report)
102 self.assertIsNotNone(result)
103 self.assertIn("Limited", result)
104
105 def test_all_old_items_returns_warning(self):
106 items = [_item(published_at="2026-01-01") for _ in range(10)]
107 report = self._report({"reddit": items})
108 result = render._assess_data_freshness(report)
109 self.assertIsNotNone(result)
110
111 def test_many_recent_items_returns_none(self):
112 from datetime import date
113 today = date.today().isoformat()
114 items = [_item(published_at=today) for _ in range(10)]
115 report = self._report({"reddit": items})
116 result = render._assess_data_freshness(report)
117 self.assertIsNone(result)
118
119 # ---------------------------------------------------------------------------
120 # render._format_date
121 # ---------------------------------------------------------------------------
122
123
124 class TestFormatDate(unittest.TestCase):
125
126 def test_high_confidence_clean(self):
127 item = _item(published_at="2026-03-10", date_confidence="high")
128 self.assertEqual(render._format_date(item), "2026-03-10")
129
130 def test_low_confidence_tagged(self):
131 item = _item(published_at="2026-03-10", date_confidence="low")
132 self.assertIn("date:low", render._format_date(item))
133
134 def test_none_item(self):
135 self.assertIn("unknown", render._format_date(None).lower())
136
137 # ---------------------------------------------------------------------------
138 # render._format_actor
139 # ---------------------------------------------------------------------------
140
141
142 class TestFormatActor(unittest.TestCase):
143
144 def test_reddit_subreddit(self):
145 item = _item(source="reddit", container="python")
146 self.assertEqual(render._format_actor(item), "r/python")
147
148 def test_x_handle(self):
149 item = _item(source="x", author="karpathy")
150 self.assertEqual(render._format_actor(item), "@karpathy")
151
152 def test_youtube_channel(self):
153 item = _item(source="youtube", author="Fireship")
154 self.assertEqual(render._format_actor(item), "Fireship")
155
156 # ---------------------------------------------------------------------------
157 # render._format_engagement
158 # ---------------------------------------------------------------------------
159
160
161 class TestFormatEngagement(unittest.TestCase):
162
163 def test_reddit_format(self):
164 item = _item(engagement={"score": 344, "num_comments": 119})
165 result = render._format_engagement(item)
166 self.assertIn("344", result)
167 self.assertIn("pts", result)
168
169 def test_empty_engagement(self):
170 item = _item(engagement={})
171 self.assertIsNone(render._format_engagement(item))
172
173 # ---------------------------------------------------------------------------
174 # render._format_corroboration
175 # ---------------------------------------------------------------------------
176
177
178 class TestFormatCorroboration(unittest.TestCase):
179
180 def test_multi_source(self):
181 c = _candidate(sources=["reddit", "x", "hackernews"])
182 result = render._format_corroboration(c)
183 self.assertIn("Also on", result)
184 self.assertIn("X", result)
185
186 def test_single_source_none(self):
187 c = _candidate(sources=["reddit"])
188 self.assertIsNone(render._format_corroboration(c))
189
190 # ---------------------------------------------------------------------------
191 # render._format_explanation
192 # ---------------------------------------------------------------------------
193
194
195 class TestFormatExplanation(unittest.TestCase):
196
197 def test_hides_fallback_sentinel(self):
198 c = _candidate(explanation="fallback-local-score")
199 self.assertIsNone(render._format_explanation(c))
200
201 def test_shows_real_explanation(self):
202 c = _candidate(explanation="Directly compares frameworks")
203 self.assertEqual(render._format_explanation(c), "Directly compares frameworks")
204
205 # ---------------------------------------------------------------------------
206 # render._fmt_pairs and _format_number
207 # ---------------------------------------------------------------------------
208
209
210 class TestFmtPairs(unittest.TestCase):
211
212 def test_basic(self):
213 self.assertEqual(render._fmt_pairs([(120, "pts"), (48, "cmt")]), "120pts, 48cmt")
214
215 def test_skips_none_and_zero(self):
216 self.assertEqual(render._fmt_pairs([(None, "pts"), (0, "cmt"), (5, "re")]), "5re")
217
218 def test_large_numbers(self):
219 self.assertIn("94,200", render._fmt_pairs([(94200, "views")]))
220
221
222 class TestFormatNumber(unittest.TestCase):
223
224 def test_comma_thousands(self):
225 self.assertEqual(render._format_number(94200), "94,200")
226
227 def test_small_integer(self):
228 self.assertEqual(render._format_number(42), "42")
229
230 # ---------------------------------------------------------------------------
231 # render._truncate
232 # ---------------------------------------------------------------------------
233
234
235 class TestTruncate(unittest.TestCase):
236
237 def test_short_text(self):
238 self.assertEqual(render._truncate("hello", 100), "hello")
239
240 def test_long_text_has_ellipsis(self):
241 result = render._truncate("a" * 200, 50)
242 self.assertTrue(result.endswith("..."))
243 self.assertEqual(len(result), 50)
244
245 # ---------------------------------------------------------------------------
246 # planner._normalize_subquery_weights
247 # ---------------------------------------------------------------------------
248
249
250 class TestNormalizeSubqueryWeights(unittest.TestCase):
251
252 def test_sums_to_one(self):
253 sqs = [
254 schema.SubQuery(label="a", search_query="a", ranking_query="a?", sources=["r"], weight=3.0),
255 schema.SubQuery(label="b", search_query="b", ranking_query="b?", sources=["r"], weight=1.0),
256 ]
257 normed = planner._normalize_subquery_weights(sqs)
258 total = sum(sq.weight for sq in normed)
259 self.assertAlmostEqual(total, 1.0)
260
261 def test_preserves_ratio(self):
262 sqs = [
263 schema.SubQuery(label="a", search_query="a", ranking_query="a?", sources=["r"], weight=4.0),
264 schema.SubQuery(label="b", search_query="b", ranking_query="b?", sources=["r"], weight=1.0),
265 ]
266 normed = planner._normalize_subquery_weights(sqs)
267 self.assertAlmostEqual(normed[0].weight / normed[1].weight, 4.0)
268
269 # ---------------------------------------------------------------------------
270 # planner._normalize_weights
271 # ---------------------------------------------------------------------------
272
273
274 class TestNormalizeWeights(unittest.TestCase):
275
276 def test_sums_to_one(self):
277 result = planner._normalize_weights({"a": 3.0, "b": 1.0})
278 self.assertAlmostEqual(sum(result.values()), 1.0)
279
280 def test_negative_clamped_to_zero(self):
281 result = planner._normalize_weights({"a": 2.0, "b": -1.0})
282 self.assertAlmostEqual(result["b"], 0.0)
283
284 # ---------------------------------------------------------------------------
285 # planner._trim_subqueries_for_depth
286 # ---------------------------------------------------------------------------
287
288
289 class TestTrimSubqueriesForDepth(unittest.TestCase):
290
291 def _sq(self, label: str = "primary", sources: list[str] = None) -> schema.SubQuery:
292 return schema.SubQuery(
293 label=label, search_query="test", ranking_query="test?",
294 sources=sources or ["reddit", "x", "grounding", "youtube", "hackernews", "polymarket"],
295 weight=1.0,
296 )
297
298 def test_quick_limits_sources(self):
299 sqs = [self._sq()]
300 result = planner._trim_subqueries_for_depth(sqs, "comparison", "quick", ["reddit", "x", "grounding"])
301 self.assertLessEqual(len(result[0].sources), 2)
302
303 def test_default_comparison_expands_via_capabilities(self):
304 available = ["reddit", "x", "grounding", "youtube", "hackernews", "tiktok", "instagram"]
305 sqs = [self._sq(sources=available)]
306 result = planner._trim_subqueries_for_depth(sqs, "comparison", "default", available)
307 # Comparison should use all capability-matched sources, not top-3
308 self.assertGreater(len(result[0].sources), 3)
309
310 def test_deep_expands_via_capabilities(self):
311 available = ["reddit", "x", "youtube", "hackernews", "polymarket"]
312 sqs = [self._sq(sources=available)]
313 result = planner._trim_subqueries_for_depth(sqs, "comparison", "deep", available)
314 # Deep comparison should also use capability expansion, not trim
315 self.assertGreaterEqual(len(result[0].sources), 4)
316
317 def test_honor_plan_sources_skips_expansion_at_default_and_deep(self):
318 available = ["reddit", "x", "youtube", "hackernews", "polymarket", "github"]
319 sqs = [self._sq(sources=["reddit", "x", "youtube"])]
320 for depth in ("default", "deep"):
321 with self.subTest(depth=depth):
322 result = planner._trim_subqueries_for_depth(
323 sqs,
324 "opinion",
325 depth,
326 available,
327 honor_plan_sources=True,
328 )
329 self.assertEqual(["reddit", "x", "youtube"], result[0].sources)
330
331 # ---------------------------------------------------------------------------
332 # signals.annotate_stream
333 # ---------------------------------------------------------------------------
334
335
336 class TestAnnotateStream(unittest.TestCase):
337
338 def test_attaches_metadata(self):
339 items = [
340 _item(engagement={"score": 100, "num_comments": 50, "upvote_ratio": 0.9}),
341 ]
342 annotated = signals.annotate_stream(items, "test query", "balanced_recent")
343 item = annotated[0]
344 self.assertIsNotNone(item.local_relevance)
345 self.assertIsNotNone(item.freshness)
346 self.assertIsNotNone(item.engagement_score)
347 self.assertIsNotNone(item.source_quality)
348 self.assertIsNotNone(item.local_rank_score)
349
350 def test_sorted_by_local_rank_score(self):
351 items = [
352 _item(item_id="low", title="irrelevant stuff", engagement={}),
353 _item(item_id="high", title="test query exact match test query", engagement={"score": 500, "num_comments": 200}),
354 ]
355 annotated = signals.annotate_stream(items, "test query", "balanced_recent")
356 self.assertEqual(annotated[0].item_id, "high")
357
358 # ---------------------------------------------------------------------------
359 # signals.prune_low_relevance
360 # ---------------------------------------------------------------------------
361
362
363 class TestPruneLowRelevance(unittest.TestCase):
364
365 def test_removes_low_relevance_items(self):
366 items = [
367 _item(item_id="good"),
368 _item(item_id="bad"),
369 ]
370 items[0].local_relevance = 0.8
371 items[1].local_relevance = 0.01
372 result = signals.prune_low_relevance(items, minimum=0.1)
373 self.assertEqual(len(result), 1)
374 self.assertEqual(result[0].item_id, "good")
375
376 def test_keeps_all_if_all_below_minimum(self):
377 items = [_item(item_id="only")]
378 items[0].local_relevance = 0.05
379 result = signals.prune_low_relevance(items, minimum=0.1)
380 self.assertEqual(len(result), 1) # fallback keeps all
381
382 # ---------------------------------------------------------------------------
383 # Bug fixes found by PR review agents
384 # ---------------------------------------------------------------------------
385
386
387 class TestDaysAgoZeroFalsy(unittest.TestCase):
388 """render._assess_data_freshness must not treat days_ago=0 as falsy."""
389
390 def _report_with_items(self, dates_list: list[str]) -> schema.Report:
391 items = [_item(published_at=d) for d in dates_list]
392 return schema.Report(
393 topic="test", range_from="2026-02-15", range_to="2026-03-17",
394 generated_at="2026-03-17T00:00:00Z",
395 provider_runtime=schema.ProviderRuntime(
396 reasoning_provider="test", planner_model="test", rerank_model="test",
397 ),
398 query_plan=schema.QueryPlan(
399 intent="comparison", freshness_mode="balanced_recent",
400 cluster_mode="debate", raw_topic="test", subqueries=[],
401 source_weights={},
402 ),
403 clusters=[], ranked_candidates=[],
404 items_by_source={"reddit": items}, errors_by_source={},
405 )
406
407 def test_items_from_today_count_as_recent(self):
408 from datetime import date
409 today = date.today().isoformat()
410 report = self._report_with_items([today] * 5)
411 warning = render._assess_data_freshness(report)
412 self.assertIsNone(warning, f"Items from today should be recent, got warning: {warning}")
413
414
415 class TestRerankBoundary(unittest.TestCase):
416 """Rerank demotion must have a clean boundary at exactly 20.0."""
417
418 def test_score_at_exactly_20_is_not_demoted(self):
419 c = _candidate()
420 c.rerank_score = 20.0
421 score_at_20 = rerank._final_score(c)
422 c.rerank_score = 50.0
423 score_at_50 = rerank._final_score(c)
424 self.assertGreater(score_at_20 / score_at_50, 0.3,
425 "Score at 20.0 should not be demoted")
426
427 def test_score_at_19_99_is_demoted(self):
428 c = _candidate()
429 c.rerank_score = 19.99
430 score_demoted = rerank._final_score(c)
431 c.rerank_score = 20.0
432 score_not_demoted = rerank._final_score(c)
433 self.assertLess(score_demoted, score_not_demoted * 0.5,
434 "Score at 19.99 should be heavily demoted vs 20.0")
435
436
437 class TestSlashFalsePositives(unittest.TestCase):
438 """Slash regex must not misclassify compound terms as comparisons."""
439
440 def test_ci_cd_is_not_comparison(self):
441 self.assertNotEqual(planner._infer_intent("CI/CD pipeline setup"), "comparison")
442
443 def test_tcp_ip_is_not_comparison(self):
444 self.assertNotEqual(planner._infer_intent("TCP/IP networking guide"), "comparison")
445
446 def test_io_is_not_comparison(self):
447 self.assertNotEqual(planner._infer_intent("I/O performance tuning"), "comparison")
448
449 def test_os_kernel_is_not_comparison(self):
450 self.assertNotEqual(planner._infer_intent("input/output buffering"), "comparison")
451
452 def test_proper_noun_slash_still_works(self):
453 self.assertEqual(planner._infer_intent("React/Vue/Svelte"), "comparison")
454
455
456 class TestGenericEngagementFormatter(unittest.TestCase):
457 """Generic formatter must not garble output for unknown sources."""
458
459 def test_xiaohongshu_engagement_not_garbled(self):
460 item = _item(source="xiaohongshu", engagement={"likes": 500, "views": 10000})
461 result = render._format_engagement(item)
462 if result is not None:
463 self.assertNotIn("likes500", result, "Key used as value prefix")
464 self.assertNotIn("views10000", result, "Key used as value prefix")
465 # Should contain numeric values, not dict keys as numbers
466 self.assertIn("500", result)
467
468 if __name__ == "__main__":
469 unittest.main()
470
471
472 class TestDefaultDepthDoesNotCapSources(unittest.TestCase):
473 """Default depth must not aggressively limit sources for any intent.
474
475 E2E testing showed factual/opinion/prediction/concept queries getting
476 0-1 sources because SOURCE_LIMITS["default"] capped them at 2-3,
477 and those 2-3 sources returned empty. v2.9.5 searched all available
478 sources and let scoring handle quality.
479 """
480
481 ALL_SOURCES = ["reddit", "x", "grounding", "youtube", "hackernews",
482 "tiktok", "instagram", "polymarket"]
483
484 def _plan_sources(self, topic: str) -> list[str]:
485 plan = planner.plan_query(
486 topic=topic,
487 available_sources=self.ALL_SOURCES,
488 requested_sources=None,
489 depth="default",
490 provider=None,
491 model=None,
492 )
493 return plan.subqueries[0].sources
494
495 def test_factual_gets_more_than_2_sources(self):
496 sources = self._plan_sources("what is quantum computing")
497 self.assertGreater(len(sources), 2,
498 f"Factual query capped at {len(sources)} sources: {sources}")
499
500 def test_opinion_gets_more_than_3_sources(self):
501 sources = self._plan_sources("thoughts on Rust")
502 self.assertGreater(len(sources), 3,
503 f"Opinion query capped at {len(sources)} sources: {sources}")
504
505 def test_prediction_gets_more_than_3_sources(self):
506 sources = self._plan_sources("odds of recession")
507 self.assertGreater(len(sources), 3,
508 f"Prediction query capped at {len(sources)} sources: {sources}")
509
510 def test_breaking_news_gets_more_than_4_sources(self):
511 sources = self._plan_sources("kanye west")
512 self.assertGreater(len(sources), 4,
513 f"Breaking news capped at {len(sources)} sources: {sources}")
514
515 def test_concept_gets_more_than_3_sources(self):
516 sources = self._plan_sources("explain transformer architecture")
517 self.assertGreater(len(sources), 3,
518 f"Concept query capped at {len(sources)} sources: {sources}")
519
520 def test_quick_mode_still_limited(self):
521 """Quick mode should remain tight for latency."""
522 plan = planner.plan_query(
523 topic="what is quantum computing",
524 available_sources=self.ALL_SOURCES,
525 requested_sources=None,
526 depth="quick",
527 provider=None,
528 model=None,
529 )
530 self.assertLessEqual(len(plan.subqueries[0].sources), 3)
531
532
533 class TestRerankWeightBalance(unittest.TestCase):
534 """Reranker weight must dominate over RRF when candidates have divergent quality."""
535
536 def test_rerank_gap_dominates_with_identical_rrf(self):
537 """Two candidates with identical RRF but rerank_scores of 80 and 40 should have a meaningful final_score gap (rerank still dominates)."""
538 high = _candidate(rrf_score=0.03, freshness=50, source_quality=0.7)
539 high.rerank_score = 80.0
540 high.final_score = rerank._final_score(high)
541
542 low = _candidate(rrf_score=0.03, freshness=50, source_quality=0.7)
543 low.rerank_score = 40.0
544 low.final_score = rerank._final_score(low)
545
546 gap = high.final_score - low.final_score
547 # Rerank weight is 0.60, so gap = 0.60 * 40 = 24 points.
548 # Engagement boost may add a small delta but rerank remains dominant.
549 self.assertGreaterEqual(gap, 23.0,
550 f"Rerank gap should be >= 23 points, got {gap:.1f}")
551
552
553 class TestXaiModelDefault(unittest.TestCase):
554 """XAI_DEFAULT must be a model that xAI's API actually accepts."""
555
556 def test_default_is_not_grok_3(self):
557 from lib import providers
558 self.assertNotEqual(providers.XAI_DEFAULT, "grok-3-fast",
559 "grok-3-fast returns HTTP 400 from xAI API")
560 self.assertNotEqual(providers.XAI_DEFAULT, "grok-3-mini-fast",
561 "grok-3-mini-fast returns HTTP 400 from xAI API")
562
563 def test_default_is_grok_4_generation(self):
564 from lib import providers
565 self.assertIn("grok-4", providers.XAI_DEFAULT,
566 f"XAI_DEFAULT should be a grok-4 model, got: {providers.XAI_DEFAULT}")
567
568
568 lines PYTHON