返回 last30days-skill
test_signals_v3.py
根目录 / tests / test_signals_v3.py
1 import math
2 import unittest
3
4 from lib import schema, signals
5 from lib.hackernews import parse_hackernews_response
6
7
8 class SignalsV3Tests(unittest.TestCase):
9 def test_reddit_engagement_uses_source_specific_formula(self):
10 item = schema.SourceItem(
11 item_id="r1",
12 source="reddit",
13 title="Title",
14 body="Body",
15 url="https://example.com",
16 engagement={"score": 99, "num_comments": 20, "upvote_ratio": 0.8},
17 metadata={"top_comments": [{"score": 10}]},
18 )
19 expected = (
20 0.50 * math.log1p(99)
21 + 0.35 * math.log1p(20)
22 + 0.05 * (0.8 * 10.0)
23 + 0.10 * math.log1p(10)
24 )
25 self.assertAlmostEqual(expected, signals.engagement_raw(item))
26
27 def test_youtube_engagement_adds_top_comment_slot(self):
28 with_comment = schema.SourceItem(
29 item_id="yt1",
30 source="youtube",
31 title="Title",
32 body="Body",
33 url="https://youtube.com/watch?v=a",
34 engagement={"views": 10000, "likes": 500, "comments": 30},
35 metadata={"top_comments": [{"score": 500}]},
36 )
37 without = schema.SourceItem(
38 item_id="yt2",
39 source="youtube",
40 title="Title",
41 body="Body",
42 url="https://youtube.com/watch?v=b",
43 engagement={"views": 10000, "likes": 500, "comments": 30},
44 metadata={"top_comments": []},
45 )
46 with_score = signals.engagement_raw(with_comment)
47 without_score = signals.engagement_raw(without)
48 self.assertIsNotNone(with_score)
49 self.assertIsNotNone(without_score)
50 self.assertGreater(with_score, without_score)
51 expected = (
52 0.45 * math.log1p(10000)
53 + 0.32 * math.log1p(500)
54 + 0.13 * math.log1p(30)
55 + 0.10 * math.log1p(500)
56 )
57 self.assertAlmostEqual(expected, with_score, places=6)
58
59 def test_youtube_engagement_empty_returns_none(self):
60 item = schema.SourceItem(
61 item_id="yt-empty",
62 source="youtube",
63 title="Title",
64 body="Body",
65 url="https://youtube.com/watch?v=e",
66 engagement={},
67 metadata={"top_comments": []},
68 )
69 self.assertIsNone(signals.engagement_raw(item))
70
71 def test_tiktok_engagement_adds_top_comment_slot(self):
72 item = schema.SourceItem(
73 item_id="tt1",
74 source="tiktok",
75 title="Title",
76 body="Body",
77 url="https://tiktok.com/@u/video/1",
78 engagement={"views": 100000, "likes": 5000, "comments": 500},
79 metadata={"top_comments": [{"score": 1200}]},
80 )
81 expected = (
82 0.45 * math.log1p(100000)
83 + 0.27 * math.log1p(5000)
84 + 0.18 * math.log1p(500)
85 + 0.10 * math.log1p(1200)
86 )
87 self.assertAlmostEqual(expected, signals.engagement_raw(item), places=6)
88
89 def test_instagram_engagement_adds_top_comment_slot(self):
90 """U2: IG gets the same 0.10 top-comment carve-out as TikTok, so a
91 highly-liked IG comment lifts its post's ranking."""
92 item = schema.SourceItem(
93 item_id="ig1",
94 source="instagram",
95 title="Title",
96 body="Body",
97 url="https://www.instagram.com/reel/ABC/",
98 engagement={"views": 100000, "likes": 5000, "comments": 500},
99 metadata={"top_comments": [{"score": 1200}]},
100 )
101 expected = (
102 0.45 * math.log1p(100000)
103 + 0.27 * math.log1p(5000)
104 + 0.18 * math.log1p(500)
105 + 0.10 * math.log1p(1200)
106 )
107 self.assertAlmostEqual(expected, signals.engagement_raw(item), places=6)
108
109 def test_instagram_comment_vote_uses_instagram_reference(self):
110 """U2: normalized_comment_vote uses the instagram reference, not the default."""
111 strength = signals.normalized_comment_vote("instagram", 5000)
112 self.assertGreater(strength, 0.0)
113 self.assertLessEqual(strength, 1.0)
114
115 def test_youtube_ranking_promotes_viral_comment_thread(self):
116 """A moderately-viewed YouTube video with a 10k-like comment should
117 outrank a slightly-higher-viewed video with no high-signal comments."""
118 viral_comment = schema.SourceItem(
119 item_id="yt-with-viral-comment",
120 source="youtube",
121 title="Deploy to Fly.io",
122 body="Deploy to Fly.io walkthrough",
123 url="https://youtube.com/watch?v=x",
124 published_at="2026-03-15",
125 engagement={"views": 5000, "likes": 200, "comments": 50},
126 metadata={"top_comments": [{"score": 10000}]},
127 )
128 higher_views = schema.SourceItem(
129 item_id="yt-higher-views-no-comment",
130 source="youtube",
131 title="Deploy to Fly.io",
132 body="Deploy to Fly.io walkthrough",
133 url="https://youtube.com/watch?v=y",
134 published_at="2026-03-15",
135 engagement={"views": 8000, "likes": 300, "comments": 60},
136 metadata={"top_comments": []},
137 )
138 ranked = signals.annotate_stream(
139 [higher_views, viral_comment],
140 ranking_query="How do I deploy on Fly.io?",
141 freshness_mode="balanced_recent",
142 )
143 self.assertEqual("yt-with-viral-comment", ranked[0].item_id)
144
145 def test_polymarket_engagement_uses_market_fields(self):
146 item = schema.SourceItem(
147 item_id="pm1",
148 source="polymarket",
149 title="Title",
150 body="Body",
151 url="https://example.com",
152 engagement={"volume": 1000, "liquidity": 250},
153 )
154 expected = (0.60 * math.log1p(1000)) + (0.40 * math.log1p(250))
155 self.assertAlmostEqual(expected, signals.engagement_raw(item))
156
157 def test_grounding_uses_generic_fallback(self):
158 item = schema.SourceItem(
159 item_id="g1",
160 source="grounding",
161 title="Title",
162 body="Body",
163 url="https://example.com",
164 engagement={"shares": 10, "reads": 100},
165 )
166 expected = (math.log1p(10) + math.log1p(100)) / 2
167 self.assertAlmostEqual(expected, signals.engagement_raw(item))
168
169 def test_annotate_stream_sorts_by_source_specific_reddit_engagement(self):
170 higher = schema.SourceItem(
171 item_id="r-high",
172 source="reddit",
173 title="High signal",
174 body="claude code skill",
175 url="https://example.com/high",
176 published_at="2026-03-15",
177 engagement={"score": 120, "num_comments": 40, "upvote_ratio": 0.9},
178 metadata={"top_comments": [{"score": 15}]},
179 )
180 lower = schema.SourceItem(
181 item_id="r-low",
182 source="reddit",
183 title="Lower signal",
184 body="claude code skill",
185 url="https://example.com/low",
186 published_at="2026-03-15",
187 engagement={"score": 4, "num_comments": 1, "upvote_ratio": 0.5},
188 metadata={"top_comments": [{"score": 1}]},
189 )
190 ranked = signals.annotate_stream(
191 [lower, higher],
192 ranking_query="What recent evidence matters for claude code skill?",
193 freshness_mode="balanced_recent",
194 )
195 self.assertEqual(["r-high", "r-low"], [item.item_id for item in ranked])
196
197 def test_local_relevance_dominates_over_high_engagement_noise(self):
198 relevant = schema.SourceItem(
199 item_id="relevant",
200 source="reddit",
201 title="Deploy to Fly.io with MCP in 60 seconds",
202 body="Deploy to Fly.io guide with concrete steps.",
203 url="https://example.com/relevant",
204 published_at="2026-03-15",
205 engagement={"score": 2, "num_comments": 0, "upvote_ratio": 0.8},
206 metadata={"top_comments": []},
207 )
208 noisy = schema.SourceItem(
209 item_id="noisy",
210 source="reddit",
211 title="BATTLEFIELD 6 GAME UPDATE 1.2.2.0",
212 body="Patch notes and gameplay discussion.",
213 url="https://example.com/noisy",
214 published_at="2026-03-15",
215 engagement={"score": 5000, "num_comments": 1200, "upvote_ratio": 0.95},
216 metadata={"top_comments": [{"score": 400}]},
217 )
218 ranked = signals.annotate_stream(
219 [noisy, relevant],
220 ranking_query="How do I deploy on Fly.io?",
221 freshness_mode="evergreen_ok",
222 )
223 self.assertEqual("relevant", ranked[0].item_id)
224
225 def test_prune_low_relevance_keeps_stronger_matches(self):
226 strong = schema.SourceItem(
227 item_id="strong",
228 source="reddit",
229 title="Deploy to Fly.io",
230 body="Step-by-step Fly.io deploy guide.",
231 url="https://example.com/strong",
232 local_relevance=0.3,
233 )
234 weak = schema.SourceItem(
235 item_id="weak",
236 source="reddit",
237 title="Battlefield update",
238 body="Patch notes.",
239 url="https://example.com/weak",
240 local_relevance=0.0,
241 )
242 pruned = signals.prune_low_relevance([strong, weak], minimum=0.1)
243 self.assertEqual(["strong"], [item.item_id for item in pruned])
244
245 def test_prune_low_relevance_falls_back_when_all_are_weak(self):
246 weak = schema.SourceItem(
247 item_id="weak",
248 source="reddit",
249 title="Generic post",
250 body="Generic body.",
251 url="https://example.com/weak",
252 metadata={"local_relevance": 0.02},
253 )
254 pruned = signals.prune_low_relevance([weak], minimum=0.1)
255 self.assertEqual(["weak"], [item.item_id for item in pruned])
256
257 # -- Iteration 1: HN engagement bug --
258
259 def test_hackernews_parse_emits_comments_key(self):
260 """parse_hackernews_response must emit 'comments' (not 'num_comments')."""
261 response = {
262 "hits": [
263 {
264 "objectID": "123",
265 "title": "Show HN: Something Cool",
266 "url": "https://example.com",
267 "author": "pg",
268 "points": 150,
269 "num_comments": 45,
270 "created_at_i": 1710720000,
271 },
272 ],
273 }
274 items = parse_hackernews_response(response, query="something cool")
275 self.assertIn("comments", items[0]["engagement"])
276 self.assertNotIn("num_comments", items[0]["engagement"])
277 self.assertEqual(items[0]["engagement"]["comments"], 45)
278
279 def test_hackernews_engagement_raw_uses_both_fields(self):
280 """engagement_raw for HN must weight both points and comments."""
281 item = schema.SourceItem(
282 item_id="hn1",
283 source="hackernews",
284 title="Show HN: Something",
285 body="Description",
286 url="https://example.com",
287 engagement={"points": 150, "comments": 45},
288 )
289 expected = 0.55 * math.log1p(150) + 0.45 * math.log1p(45)
290 result = signals.engagement_raw(item)
291 self.assertIsNotNone(result)
292 self.assertAlmostEqual(expected, result)
293 # Verify comments actually contributed (not just points)
294 points_only = 0.55 * math.log1p(150)
295 self.assertGreater(result, points_only)
296
297 # -- Iteration 4: Missing engagement formula tests --
298
299 def test_x_engagement_dominant_weight(self):
300 """X: likes at 0.55 should dominate over quotes at 0.05."""
301 item = schema.SourceItem(
302 item_id="x1", source="x", title="T", body="B",
303 url="https://example.com",
304 engagement={"likes": 100, "reposts": 100, "replies": 100, "quotes": 100},
305 )
306 result = signals.engagement_raw(item)
307 self.assertIsNotNone(result)
308 expected = (
309 0.55 * math.log1p(100)
310 + 0.25 * math.log1p(100)
311 + 0.15 * math.log1p(100)
312 + 0.05 * math.log1p(100)
313 )
314 self.assertAlmostEqual(expected, result)
315
316 def test_x_engagement_all_zero_returns_none(self):
317 item = schema.SourceItem(
318 item_id="x2", source="x", title="T", body="B",
319 url="https://example.com",
320 engagement={"likes": 0, "reposts": 0, "replies": 0, "quotes": 0},
321 )
322 self.assertIsNone(signals.engagement_raw(item))
323
324 def test_x_engagement_missing_fields(self):
325 """Missing fields default to 0, no crash."""
326 item = schema.SourceItem(
327 item_id="x3", source="x", title="T", body="B",
328 url="https://example.com",
329 engagement={"likes": 50},
330 )
331 result = signals.engagement_raw(item)
332 self.assertIsNotNone(result)
333 expected = 0.55 * math.log1p(50)
334 self.assertAlmostEqual(expected, result)
335
336 def test_youtube_engagement_dominant_weight(self):
337 """YouTube: views at 0.45 should dominate. With no top-comment data,
338 the remaining 0.90 of weight is split views/likes/comments 0.45/0.32/0.13."""
339 item = schema.SourceItem(
340 item_id="yt1", source="youtube", title="T", body="B",
341 url="https://example.com",
342 engagement={"views": 10000, "likes": 500, "comments": 80},
343 )
344 result = signals.engagement_raw(item)
345 self.assertIsNotNone(result)
346 expected = (
347 0.45 * math.log1p(10000)
348 + 0.32 * math.log1p(500)
349 + 0.13 * math.log1p(80)
350 )
351 self.assertAlmostEqual(expected, result)
352
353 def test_youtube_engagement_all_zero_returns_none(self):
354 item = schema.SourceItem(
355 item_id="yt2", source="youtube", title="T", body="B",
356 url="https://example.com",
357 engagement={"views": 0, "likes": 0, "comments": 0},
358 )
359 self.assertIsNone(signals.engagement_raw(item))
360
361 def test_youtube_engagement_missing_fields(self):
362 item = schema.SourceItem(
363 item_id="yt3", source="youtube", title="T", body="B",
364 url="https://example.com",
365 engagement={"views": 5000},
366 )
367 result = signals.engagement_raw(item)
368 self.assertIsNotNone(result)
369 expected = 0.45 * math.log1p(5000)
370 self.assertAlmostEqual(expected, result)
371
372 def test_tiktok_engagement_dominant_weight(self):
373 item = schema.SourceItem(
374 item_id="tt1", source="tiktok", title="T", body="B",
375 url="https://example.com",
376 engagement={"views": 50000, "likes": 3000, "comments": 200},
377 )
378 result = signals.engagement_raw(item)
379 self.assertIsNotNone(result)
380 expected = (
381 0.45 * math.log1p(50000)
382 + 0.27 * math.log1p(3000)
383 + 0.18 * math.log1p(200)
384 )
385 self.assertAlmostEqual(expected, result)
386
387 def test_tiktok_engagement_all_zero_returns_none(self):
388 item = schema.SourceItem(
389 item_id="tt2", source="tiktok", title="T", body="B",
390 url="https://example.com",
391 engagement={"views": 0, "likes": 0, "comments": 0},
392 )
393 self.assertIsNone(signals.engagement_raw(item))
394
395 def test_tiktok_engagement_missing_fields(self):
396 item = schema.SourceItem(
397 item_id="tt3", source="tiktok", title="T", body="B",
398 url="https://example.com",
399 engagement={"likes": 1000},
400 )
401 result = signals.engagement_raw(item)
402 self.assertIsNotNone(result)
403 expected = 0.27 * math.log1p(1000)
404 self.assertAlmostEqual(expected, result)
405
406 def test_instagram_engagement_dominant_weight(self):
407 item = schema.SourceItem(
408 item_id="ig1", source="instagram", title="T", body="B",
409 url="https://example.com",
410 engagement={"views": 8000, "likes": 1500, "comments": 100},
411 )
412 result = signals.engagement_raw(item)
413 self.assertIsNotNone(result)
414 # U2: IG now uses _instagram_engagement (video-shaped, with a 0.10
415 # top-comment carve-out); no top comment here so that term is 0.
416 expected = (
417 0.45 * math.log1p(8000)
418 + 0.27 * math.log1p(1500)
419 + 0.18 * math.log1p(100)
420 )
421 self.assertAlmostEqual(expected, result)
422
423 def test_instagram_engagement_all_zero_returns_none(self):
424 item = schema.SourceItem(
425 item_id="ig2", source="instagram", title="T", body="B",
426 url="https://example.com",
427 engagement={"views": 0, "likes": 0, "comments": 0},
428 )
429 self.assertIsNone(signals.engagement_raw(item))
430
431 def test_instagram_engagement_missing_fields(self):
432 item = schema.SourceItem(
433 item_id="ig3", source="instagram", title="T", body="B",
434 url="https://example.com",
435 engagement={"comments": 50},
436 )
437 result = signals.engagement_raw(item)
438 self.assertIsNotNone(result)
439 expected = 0.18 * math.log1p(50)
440 self.assertAlmostEqual(expected, result)
441
442 def test_hackernews_engagement_all_zero_returns_none(self):
443 item = schema.SourceItem(
444 item_id="hn2", source="hackernews", title="T", body="B",
445 url="https://example.com",
446 engagement={"points": 0, "comments": 0},
447 )
448 self.assertIsNone(signals.engagement_raw(item))
449
450 def test_hackernews_engagement_missing_fields(self):
451 item = schema.SourceItem(
452 item_id="hn3", source="hackernews", title="T", body="B",
453 url="https://example.com",
454 engagement={"points": 75},
455 )
456 result = signals.engagement_raw(item)
457 self.assertIsNotNone(result)
458 expected = 0.55 * math.log1p(75)
459 self.assertAlmostEqual(expected, result)
460
461 def test_bluesky_engagement_dominant_weight(self):
462 """Bluesky: likes at 0.40 should dominate over quotes at 0.10."""
463 item = schema.SourceItem(
464 item_id="bs1", source="bluesky", title="T", body="B",
465 url="https://example.com",
466 engagement={"likes": 200, "reposts": 50, "replies": 30, "quotes": 10},
467 )
468 result = signals.engagement_raw(item)
469 self.assertIsNotNone(result)
470 expected = (
471 0.40 * math.log1p(200)
472 + 0.30 * math.log1p(50)
473 + 0.20 * math.log1p(30)
474 + 0.10 * math.log1p(10)
475 )
476 self.assertAlmostEqual(expected, result)
477
478 def test_bluesky_engagement_all_zero_returns_none(self):
479 item = schema.SourceItem(
480 item_id="bs2", source="bluesky", title="T", body="B",
481 url="https://example.com",
482 engagement={"likes": 0, "reposts": 0, "replies": 0, "quotes": 0},
483 )
484 self.assertIsNone(signals.engagement_raw(item))
485
486 def test_bluesky_engagement_missing_fields(self):
487 item = schema.SourceItem(
488 item_id="bs3", source="bluesky", title="T", body="B",
489 url="https://example.com",
490 engagement={"likes": 100, "replies": 20},
491 )
492 result = signals.engagement_raw(item)
493 self.assertIsNotNone(result)
494 expected = 0.40 * math.log1p(100) + 0.20 * math.log1p(20)
495 self.assertAlmostEqual(expected, result)
496
497 def test_truthsocial_engagement_dominant_weight(self):
498 """Truth Social: likes at 0.45 should dominate over replies at 0.25."""
499 item = schema.SourceItem(
500 item_id="ts1", source="truthsocial", title="T", body="B",
501 url="https://example.com",
502 engagement={"likes": 500, "reposts": 100, "replies": 50},
503 )
504 result = signals.engagement_raw(item)
505 self.assertIsNotNone(result)
506 expected = (
507 0.45 * math.log1p(500)
508 + 0.30 * math.log1p(100)
509 + 0.25 * math.log1p(50)
510 )
511 self.assertAlmostEqual(expected, result)
512
513 def test_truthsocial_engagement_all_zero_returns_none(self):
514 item = schema.SourceItem(
515 item_id="ts2", source="truthsocial", title="T", body="B",
516 url="https://example.com",
517 engagement={"likes": 0, "reposts": 0, "replies": 0},
518 )
519 self.assertIsNone(signals.engagement_raw(item))
520
521 def test_truthsocial_engagement_missing_fields(self):
522 item = schema.SourceItem(
523 item_id="ts3", source="truthsocial", title="T", body="B",
524 url="https://example.com",
525 engagement={"reposts": 80},
526 )
527 result = signals.engagement_raw(item)
528 self.assertIsNotNone(result)
529 expected = 0.30 * math.log1p(80)
530 self.assertAlmostEqual(expected, result)
531
532 # -- Fix 5: Rebalance engagement weight --
533
534 def test_engagement_weight_meaningful_for_social_ranking(self):
535 """Engagement must have enough weight to differentiate otherwise-equal items."""
536 high_engagement = schema.SourceItem(
537 item_id="viral",
538 source="x",
539 title="Trending topic discussion",
540 body="Popular social post",
541 url="https://example.com/viral",
542 published_at="2026-03-15",
543 engagement={"likes": 50000, "reposts": 5000, "replies": 2000, "quotes": 500},
544 )
545 low_engagement = schema.SourceItem(
546 item_id="quiet",
547 source="x",
548 title="Trending topic discussion",
549 body="Popular social post",
550 url="https://example.com/quiet",
551 published_at="2026-03-15",
552 engagement={"likes": 10, "reposts": 1, "replies": 0, "quotes": 0},
553 )
554 ranked = signals.annotate_stream(
555 [low_engagement, high_engagement],
556 ranking_query="trending topic discussion",
557 freshness_mode="balanced_recent",
558 )
559 high_score = ranked[0].local_rank_score
560 low_score = ranked[1].local_rank_score
561 gap = high_score - low_score
562 # With 10% engagement weight, the gap should be >= 0.06
563 # With 5% weight, gap would be ~0.04
564 self.assertGreaterEqual(gap, 0.06,
565 f"Engagement gap should be >= 0.06 with 10% weight, got {gap:.4f}")
566
567 # -- Fix 4: Lower prune threshold for social media --
568
569 def test_prune_keeps_social_items_above_003(self):
570 """Social media items with low but non-trivial relevance should survive pruning."""
571 social = schema.SourceItem(
572 item_id="social",
573 source="x",
574 title="Viral tweet about topic",
575 body="Short social post",
576 url="https://example.com/social",
577 metadata={"local_relevance": 0.05},
578 )
579 strong = schema.SourceItem(
580 item_id="strong",
581 source="grounding",
582 title="Detailed article about topic",
583 body="In-depth analysis",
584 url="https://example.com/strong",
585 metadata={"local_relevance": 0.4},
586 )
587 pruned = signals.prune_low_relevance([strong, social])
588 ids = [item.item_id for item in pruned]
589 self.assertIn("social", ids, "Item with relevance 0.05 should survive pruning")
590 self.assertIn("strong", ids)
591
592 # -- Unit 3: YouTube high-engagement relevance floor --
593
594 def test_youtube_high_engagement_gets_relevance_floor(self):
595 """YouTube video with >100K views gets at least 0.3 relevance even with low text overlap."""
596 item = schema.SourceItem(
597 item_id="yt-official",
598 source="youtube",
599 title="YE - FATHER (feat. TRAVIS SCOTT)",
600 body="Official music video",
601 url="https://youtube.com/watch?v=abc",
602 engagement={"views": 8_000_000, "likes": 422_000, "comments": 5000},
603 )
604 rel = signals.local_relevance(item, "kanye west")
605 self.assertGreaterEqual(rel, 0.3, f"High-engagement YouTube should get >= 0.3 relevance, got {rel}")
606
607 def test_youtube_low_engagement_no_floor(self):
608 """YouTube video with <100K views does NOT get the relevance floor."""
609 item = schema.SourceItem(
610 item_id="yt-small",
611 source="youtube",
612 title="Random unrelated video title",
613 body="Nothing relevant here",
614 url="https://youtube.com/watch?v=xyz",
615 engagement={"views": 500, "likes": 10, "comments": 1},
616 )
617 rel = signals.local_relevance(item, "kanye west")
618 self.assertLess(rel, 0.3, f"Low-engagement YouTube should not get floor, got {rel}")
619
620 def test_non_youtube_high_engagement_no_floor(self):
621 """Non-YouTube items with high engagement do NOT get the YouTube floor."""
622 item = schema.SourceItem(
623 item_id="reddit-viral",
624 source="reddit",
625 title="Completely unrelated post",
626 body="Nothing about the topic",
627 url="https://reddit.com/r/test",
628 engagement={"score": 50000, "num_comments": 3000},
629 )
630 rel = signals.local_relevance(item, "kanye west")
631 self.assertLess(rel, 0.3, f"Non-YouTube item should not get YouTube floor, got {rel}")
632
633 # -- Unit 8: Engagement floor for TikTok/Instagram --
634
635 def test_tiktok_below_1000_views_pruned(self):
636 """TikTok items with <1000 views should be pruned when other sources exist."""
637 spam = schema.SourceItem(
638 item_id="tt-spam", source="tiktok", title="AI news clip", body="Generic",
639 url="https://tiktok.com/spam",
640 local_relevance=0.4, engagement={"views": 500, "likes": 10, "comments": 1},
641 )
642 good = schema.SourceItem(
643 item_id="r-good", source="reddit", title="Good discussion", body="Quality",
644 url="https://reddit.com/good",
645 local_relevance=0.5, engagement_score=50,
646 )
647 pruned = signals.prune_low_relevance([good, spam])
648 ids = [item.item_id for item in pruned]
649 self.assertNotIn("tt-spam", ids, "TikTok with 500 views should be pruned")
650 self.assertIn("r-good", ids)
651
652 def test_instagram_below_1000_views_pruned(self):
653 """Instagram items with <1000 views should be pruned when other sources exist."""
654 spam = schema.SourceItem(
655 item_id="ig-spam", source="instagram", title="Repost clip", body="Generic",
656 url="https://instagram.com/spam",
657 local_relevance=0.4, engagement={"views": 200, "likes": 5, "comments": 0},
658 )
659 good = schema.SourceItem(
660 item_id="x-good", source="x", title="Good tweet", body="Quality",
661 url="https://x.com/good",
662 local_relevance=0.5, engagement_score=50,
663 )
664 pruned = signals.prune_low_relevance([good, spam])
665 ids = [item.item_id for item in pruned]
666 self.assertNotIn("ig-spam", ids, "Instagram with 200 views should be pruned")
667
668 def test_tiktok_above_1000_views_kept(self):
669 """TikTok items with >=1000 views should survive pruning."""
670 good_tt = schema.SourceItem(
671 item_id="tt-good", source="tiktok", title="Popular clip", body="Relevant",
672 url="https://tiktok.com/good",
673 local_relevance=0.4, engagement={"views": 5000, "likes": 200, "comments": 30},
674 )
675 other = schema.SourceItem(
676 item_id="r-other", source="reddit", title="Reddit post", body="Relevant",
677 url="https://reddit.com/other",
678 local_relevance=0.5, engagement_score=50,
679 )
680 pruned = signals.prune_low_relevance([other, good_tt])
681 ids = [item.item_id for item in pruned]
682 self.assertIn("tt-good", ids, "TikTok with 5000 views should be kept")
683
684 def test_tiktok_sole_source_not_pruned(self):
685 """When TikTok is the only source, low-view items should NOT be pruned."""
686 items = [
687 schema.SourceItem(
688 item_id=f"tt-{i}", source="tiktok", title=f"Clip {i}", body="Content",
689 url=f"https://tiktok.com/{i}",
690 local_relevance=0.4, engagement={"views": 300, "likes": 5, "comments": 0},
691 )
692 for i in range(3)
693 ]
694 pruned = signals.prune_low_relevance(items)
695 self.assertEqual(len(pruned), 3, "Sole-source TikTok items should all survive")
696
697 def test_non_video_sources_unaffected_by_floor(self):
698 """Reddit/X items should not be affected by the video engagement floor."""
699 low_eng_x = schema.SourceItem(
700 item_id="x-low", source="x", title="Tweet", body="Topic discussion",
701 url="https://x.com/low",
702 local_relevance=0.5, engagement={"likes": 2, "reposts": 0},
703 engagement_score=5,
704 )
705 other = schema.SourceItem(
706 item_id="r-other", source="reddit", title="Post", body="Topic",
707 url="https://reddit.com/other",
708 local_relevance=0.5, engagement_score=50,
709 )
710 pruned = signals.prune_low_relevance([other, low_eng_x])
711 ids = [item.item_id for item in pruned]
712 self.assertIn("x-low", ids, "X items should not be affected by video floor")
713
714 def test_aspiresnippets_scenario(self):
715 """@aspiresnippets scenario: 5 TikTok items with 200-700 views all pruned."""
716 spam_items = [
717 schema.SourceItem(
718 item_id=f"aspire-{i}", source="tiktok", title=f"AI news {i}", body="Generic clip",
719 url=f"https://tiktok.com/aspire/{i}",
720 local_relevance=0.3, engagement={"views": 200 + i * 100, "likes": 5, "comments": 0},
721 )
722 for i in range(5)
723 ]
724 good = schema.SourceItem(
725 item_id="good-yt", source="youtube", title="In-depth analysis", body="Quality content",
726 url="https://youtube.com/good",
727 local_relevance=0.6, engagement_score=70,
728 )
729 pruned = signals.prune_low_relevance([good] + spam_items)
730 aspire_ids = [item.item_id for item in pruned if item.item_id.startswith("aspire")]
731 self.assertEqual(len(aspire_ids), 0, f"All @aspiresnippets items should be pruned, got {aspire_ids}")
732
733 # -- Fix 468: YouTube items with transcripts survive relevance pruning --
734
735 def test_youtube_with_transcript_survives_pruning_even_with_low_relevance(self):
736 """A YouTube item with a non-empty snippet (transcript) should not be
737 pruned even if its title-only relevance is below the threshold."""
738 has_transcript = schema.SourceItem(
739 item_id="yt-transcript",
740 source="youtube",
741 title="Short title",
742 body="Short body",
743 url="https://youtube.com/watch?v=abc",
744 snippet="This is a detailed transcript about the topic with substantive discussion...",
745 local_relevance=0.05,
746 )
747 strong = schema.SourceItem(
748 item_id="yt-strong",
749 source="youtube",
750 title="Strong video",
751 body="Detailed analysis of the topic",
752 url="https://youtube.com/watch?v=strong",
753 snippet="Detailed transcript content about the topic",
754 local_relevance=0.6,
755 )
756 pruned = signals.prune_low_relevance([strong, has_transcript], minimum=0.15)
757 ids = [item.item_id for item in pruned]
758 self.assertIn("yt-transcript", ids,
759 "YouTube item with transcript should survive pruning")
760 self.assertIn("yt-strong", ids, "Strong item should survive")
761
762 def test_youtube_without_transcript_is_pruned_normally(self):
763 """A YouTube item with no transcript (empty snippet) and low relevance
764 should still be pruned when stronger items exist."""
765 no_transcript = schema.SourceItem(
766 item_id="yt-no-transcript",
767 source="youtube",
768 title="Short title",
769 body="Short body",
770 url="https://youtube.com/watch?v=xyz",
771 snippet="",
772 local_relevance=0.05,
773 )
774 strong = schema.SourceItem(
775 item_id="yt-strong",
776 source="youtube",
777 title="Strong video",
778 body="Detailed analysis of the topic",
779 url="https://youtube.com/watch?v=strong",
780 snippet="Detailed transcript content about the topic",
781 local_relevance=0.6,
782 )
783 pruned = signals.prune_low_relevance([strong, no_transcript], minimum=0.15)
784 ids = [item.item_id for item in pruned]
785 self.assertIn("yt-strong", ids, "Strong item should survive")
786 self.assertNotIn("yt-no-transcript", ids,
787 "YouTube item without transcript should be pruned normally")
788
789 def test_youtube_transcript_exemption_does_not_affect_other_sources(self):
790 """Non-YouTube items with low relevance are still pruned even if they
791 have a non-empty snippet (the exemption is YouTube-specific)."""
792 reddit_with_snippet = schema.SourceItem(
793 item_id="reddit-snippet",
794 source="reddit",
795 title="Short title",
796 body="Short body",
797 url="https://reddit.com/r/test",
798 snippet="Some snippet content",
799 local_relevance=0.05,
800 )
801 strong = schema.SourceItem(
802 item_id="strong",
803 source="reddit",
804 title="Strong post",
805 body="Detailed analysis of the topic",
806 url="https://reddit.com/r/strong",
807 local_relevance=0.5,
808 )
809 pruned = signals.prune_low_relevance([strong, reddit_with_snippet], minimum=0.15)
810 ids = [item.item_id for item in pruned]
811 self.assertIn("strong", ids, "Strong item should survive")
812 self.assertNotIn("reddit-snippet", ids,
813 "Non-YouTube items should still be pruned by relevance threshold")
814
815
816 if __name__ == "__main__":
817 unittest.main()
818
818 lines PYTHON