| 1 | """Named creator accounts (--ig-creators / TikTok --creators) are first-party evidence. |
| 2 | |
| 3 | The production failure this pins (issue #1101): creator reels are fetched and |
| 4 | merged into the stream correctly, but ``signals.prune_low_relevance`` then |
| 5 | drops them - a creator's caption rarely contains the topic's literal tokens, |
| 6 | so it scores under the relevance floor, and the first-party exemption covered |
| 7 | only --x-handle / --github-user / --x-related / topic-mention handles. A mixed |
| 8 | batch (keyword hits survive, creator reels fail) therefore loses every creator |
| 9 | item silently: the ``filtered or items`` rescue only fires when *everything* |
| 10 | fails, and no drop was logged. |
| 11 | """ |
| 12 | |
| 13 | import contextlib |
| 14 | import io |
| 15 | from datetime import datetime, timezone |
| 16 | from unittest.mock import patch |
| 17 | |
| 18 | import pytest |
| 19 | |
| 20 | from lib import pipeline, schema, signals |
| 21 | |
| 22 | |
| 23 | def _shortform_item(item_id, source, author, relevance, engagement=None): |
| 24 | item = schema.SourceItem( |
| 25 | item_id=item_id, |
| 26 | source=source, |
| 27 | title="", |
| 28 | body="post body", |
| 29 | url=f"https://{source}.com/{author}/{item_id}", |
| 30 | author=author, |
| 31 | engagement=engagement or {}, |
| 32 | ) |
| 33 | item.local_relevance = relevance |
| 34 | return item |
| 35 | |
| 36 | |
| 37 | def _annotate(items): |
| 38 | """Populate engagement_score the way the real pipeline does.""" |
| 39 | scores = signals.normalize([signals.engagement_raw(i) for i in items]) |
| 40 | for item, score in zip(items, scores, strict=True): |
| 41 | item.engagement_score = score |
| 42 | return items |
| 43 | |
| 44 | |
| 45 | def _mixed_ig_batch(): |
| 46 | """The named creator's reel scores 0.0 (a caption rarely repeats the |
| 47 | topic's tokens) and sits under the 1000-view floor; the keyword hit and |
| 48 | the X post clear their floors.""" |
| 49 | return [ |
| 50 | _shortform_item("ig-creator", "instagram", "linkuptv", 0.0, |
| 51 | {"views": 400, "likes": 12, "comments": 1}), |
| 52 | _shortform_item("ig-random", "instagram", "random_reposter", 0.02, |
| 53 | {"views": 300, "likes": 4, "comments": 0}), |
| 54 | _shortform_item("x-hit", "x", "someone", 0.4, |
| 55 | {"likes": 90, "reposts": 9}), |
| 56 | ] |
| 57 | |
| 58 | |
| 59 | def test_named_ig_creator_survives_mixed_batch(): |
| 60 | items = _annotate(_mixed_ig_batch()) |
| 61 | kept = signals.prune_low_relevance(items, first_party_handles={"linkuptv"}) |
| 62 | ids = [item.item_id for item in kept] |
| 63 | assert "ig-creator" in ids, ( |
| 64 | "a reel by an account named via --ig-creators is evidence by " |
| 65 | "provenance; pruning it from a mixed batch is the #1101 defect" |
| 66 | ) |
| 67 | assert "ig-random" not in ids, ( |
| 68 | "the exemption must stay scoped to named creators, not blanket-keep " |
| 69 | "low-relevance reels" |
| 70 | ) |
| 71 | |
| 72 | |
| 73 | def test_named_tiktok_creator_survives_mixed_batch(): |
| 74 | """TikTok --creators had the identical gap; tiktok author is the unique_id |
| 75 | handle, so the normalized comparison is symmetric with Instagram.""" |
| 76 | items = _annotate([ |
| 77 | _shortform_item("tt-creator", "tiktok", "mixtapemadness", 0.0, |
| 78 | {"views": 800, "likes": 30, "comments": 2}), |
| 79 | _shortform_item("x-hit", "x", "someone", 0.4, |
| 80 | {"likes": 90, "reposts": 9}), |
| 81 | ]) |
| 82 | kept = signals.prune_low_relevance(items, first_party_handles={"mixtapemadness"}) |
| 83 | assert "tt-creator" in [item.item_id for item in kept] |
| 84 | |
| 85 | |
| 86 | def test_named_creator_handle_normalization(): |
| 87 | """@ prefix and mixed case on either side must still match.""" |
| 88 | items = _annotate([ |
| 89 | _shortform_item("ig-creator", "instagram", "@LinkUpTV", 0.0, |
| 90 | {"views": 400, "likes": 12, "comments": 1}), |
| 91 | _shortform_item("x-hit", "x", "someone", 0.4, |
| 92 | {"likes": 90, "reposts": 9}), |
| 93 | ]) |
| 94 | kept = signals.prune_low_relevance(items, first_party_handles={"linkuptv"}) |
| 95 | assert "ig-creator" in [item.item_id for item in kept] |
| 96 | |
| 97 | |
| 98 | def test_unnamed_creator_content_still_pruned(): |
| 99 | """Characterizes the defect: without the named-creator wiring the batch |
| 100 | drops them. If this starts failing, the floor changed and the exemption's |
| 101 | justification needs rechecking.""" |
| 102 | items = _annotate(_mixed_ig_batch()) |
| 103 | kept = signals.prune_low_relevance(items) |
| 104 | assert all(item.author != "linkuptv" for item in kept) |
| 105 | |
| 106 | |
| 107 | def test_creator_handles_scoped_to_their_platform(): |
| 108 | """The wiring this whole file depends on: creator flags must produce the |
| 109 | platform-scoped exemption map the prunes receive, normalized the same way |
| 110 | item authors are.""" |
| 111 | scoped = pipeline._creator_first_party_by_source( |
| 112 | ["@MixtapeMadness", " "], ["LinkUpTV"] |
| 113 | ) |
| 114 | assert scoped == { |
| 115 | "instagram": {"linkuptv"}, |
| 116 | "tiktok": {"mixtapemadness"}, |
| 117 | } |
| 118 | |
| 119 | |
| 120 | def test_ig_creator_does_not_exempt_same_name_tiktok_account(): |
| 121 | """An account named via --ig-creators is an Instagram account; a same-name |
| 122 | TikTok account is a different person and gets no exemption.""" |
| 123 | items = _annotate([ |
| 124 | _shortform_item("tt-same-name", "tiktok", "linkuptv", 0.0, |
| 125 | {"views": 800, "likes": 30, "comments": 2}), |
| 126 | _shortform_item("x-hit", "x", "someone", 0.4, |
| 127 | {"likes": 90, "reposts": 9}), |
| 128 | ]) |
| 129 | kept = signals.prune_low_relevance( |
| 130 | items, first_party_by_source={"instagram": {"linkuptv"}} |
| 131 | ) |
| 132 | assert "tt-same-name" not in [item.item_id for item in kept] |
| 133 | |
| 134 | |
| 135 | def test_scoped_exemption_keeps_creator_on_own_platform(): |
| 136 | items = _annotate(_mixed_ig_batch()) |
| 137 | kept = signals.prune_low_relevance( |
| 138 | items, first_party_by_source={"instagram": {"linkuptv"}} |
| 139 | ) |
| 140 | ids = [item.item_id for item in kept] |
| 141 | assert "ig-creator" in ids |
| 142 | assert "ig-random" not in ids |
| 143 | |
| 144 | |
| 145 | def test_deferred_x_floor_ignores_creator_only_handles(): |
| 146 | """The deferred X prune receives resolved_handles minus the creator sets: |
| 147 | an X account that merely shares a creator's handle still faces the floor.""" |
| 148 | items = _annotate([ |
| 149 | _shortform_item("x-same-name", "x", "linkuptv", 0.0, {"likes": 0}), |
| 150 | _shortform_item("x-hit", "x", "someone", 0.4, {"likes": 90, "reposts": 9}), |
| 151 | ]) |
| 152 | kept = signals.prune_low_relevance( |
| 153 | items, |
| 154 | first_party_handles=set(), # creator handles were subtracted out |
| 155 | first_party_by_source={"instagram": {"linkuptv"}}, |
| 156 | ) |
| 157 | assert "x-same-name" not in [item.item_id for item in kept] |
| 158 | |
| 159 | |
| 160 | def _raw_ig(item_id, author, text, views): |
| 161 | return { |
| 162 | "id": item_id, |
| 163 | "text": text, |
| 164 | "url": f"https://instagram.com/reel/{item_id}", |
| 165 | "author_name": author, |
| 166 | "date": "2026-09-01", |
| 167 | "engagement": {"views": views, "likes": 3, "comments": 0}, |
| 168 | } |
| 169 | |
| 170 | |
| 171 | def _normalize_quietly(raw, ranking_query="fly.io deploy guide"): |
| 172 | buf = io.StringIO() |
| 173 | with contextlib.redirect_stderr(buf): |
| 174 | kept = pipeline._normalize_score_dedupe( |
| 175 | "instagram", |
| 176 | raw, |
| 177 | "2026-08-06", |
| 178 | "2026-09-05", |
| 179 | freshness_mode="balanced_recent", |
| 180 | ranking_query=ranking_query, |
| 181 | ) |
| 182 | return kept, buf.getvalue() |
| 183 | |
| 184 | |
| 185 | def test_prune_drop_is_logged_with_source_and_count(): |
| 186 | raw = [ |
| 187 | _raw_ig("keep1", "acct", "fly.io deploy guide walkthrough", 5000), |
| 188 | _raw_ig("drop1", "acct2", "unrelated dance clip", 100), |
| 189 | ] |
| 190 | kept, logs = _normalize_quietly(raw) |
| 191 | assert [item.item_id for item in kept] == ["keep1"], ( |
| 192 | "the off-topic low-engagement reel should still be pruned" |
| 193 | ) |
| 194 | assert "prune" in logs.lower() and "1" in logs, ( |
| 195 | "a silent drop is the observability half of #1101: the reporter saw " |
| 196 | "36 reels fetched, 0 reported, and no line explaining why" |
| 197 | ) |
| 198 | |
| 199 | |
| 200 | def test_no_log_when_nothing_is_pruned(): |
| 201 | raw = [ |
| 202 | _raw_ig("keep1", "acct", "fly.io deploy guide walkthrough", 5000), |
| 203 | _raw_ig("keep2", "acct2", "deploying on fly.io with a Dockerfile", 3000), |
| 204 | ] |
| 205 | kept, logs = _normalize_quietly(raw) |
| 206 | assert len(kept) == 2 |
| 207 | assert "prune" not in logs.lower(), ( |
| 208 | "a clean stream must not emit a misleading drop line" |
| 209 | ) |
| 210 | |
| 211 | |
| 212 | def test_all_weak_rescue_is_not_logged_as_a_drop(): |
| 213 | """When every item fails and prune_low_relevance keeps the originals, the |
| 214 | stream lost nothing - logging a drop would be false.""" |
| 215 | raw = [ |
| 216 | _raw_ig("weak1", "acct", "unrelated clip one", 100), |
| 217 | _raw_ig("weak2", "acct2", "unrelated clip two", 90), |
| 218 | ] |
| 219 | kept, logs = _normalize_quietly(raw) |
| 220 | assert len(kept) == 2, "the filtered-or-items rescue keeps weak sole batches" |
| 221 | assert "prune" not in logs.lower() |
| 222 | def test_creator_only_handles_preserves_x_provenance(): |
| 223 | """--x-handle foo --ig-creators foo names the same person twice; the X |
| 224 | exemption must survive the creator-only subtraction (Greptile re-review |
| 225 | on f49c7bf).""" |
| 226 | creators = pipeline._creator_first_party_by_source([], ["Foo"]) |
| 227 | real_x = {"foo"} # normalized --x-handle / @mention / Phase 2 discovery |
| 228 | creator_only = pipeline._creator_only_handles(creators, real_x) |
| 229 | assert creator_only == set(), "foo has X provenance and is not creator-only" |
| 230 | creators = pipeline._creator_first_party_by_source([], ["bar"]) |
| 231 | creator_only = pipeline._creator_only_handles(creators, real_x) |
| 232 | assert creator_only == {"bar"}, "bar was named only as an IG creator" |
| 233 | |
| 234 | |
| 235 | def test_topic_token_alone_is_not_x_provenance(): |
| 236 | """A creator handle that also appears as a plain topic token keeps no X |
| 237 | exemption: X provenance is real_x_handles only (Greptile re-review on |
| 238 | 2a62aea). The pipeline passes real_x_handles as the provenance set, so a |
| 239 | topic-token-only handle lands in creator_only and out of x_floor_handles.""" |
| 240 | creators = pipeline._creator_first_party_by_source([], ["linkuptv"]) |
| 241 | # topic mention of "linkuptv" without an @ or X flag: not in real_x_handles |
| 242 | creator_only = pipeline._creator_only_handles(creators, {"someone_else"}) |
| 243 | assert creator_only == {"linkuptv"} |
| 244 | |
| 245 | |
| 246 | @pytest.mark.parametrize("depth", ["quick", "default"]) |
| 247 | def test_creator_provenance_survives_pipeline_ranking(depth): |
| 248 | raw = [ |
| 249 | _raw_ig("creator", "linkuptv", "an unrelated caption", 400), |
| 250 | _raw_ig("keyword", "other", "fly.io deploy guide walkthrough", 5000), |
| 251 | ] |
| 252 | for item in raw: |
| 253 | item["date"] = datetime.now(timezone.utc).date().isoformat() |
| 254 | with patch.object(pipeline, "_retrieve_stream", return_value=(raw, {})), \ |
| 255 | patch.object(pipeline, "_normalize_score_dedupe", wraps=pipeline._normalize_score_dedupe) as normalize: |
| 256 | report = pipeline.run( |
| 257 | topic="fly.io deploy guide", |
| 258 | config={}, |
| 259 | requested_sources=["instagram"], |
| 260 | ig_creators=["linkuptv"], |
| 261 | depth=depth, |
| 262 | mock=True, |
| 263 | web_backend="none", |
| 264 | ) |
| 265 | assert normalize.call_args_list |
| 266 | if depth == "default": |
| 267 | assert len(normalize.call_args_list) >= 2, "thin retry must also carry the source map" |
| 268 | assert all( |
| 269 | call.kwargs["first_party_by_source"]["instagram"] == {"linkuptv"} |
| 270 | for call in normalize.call_args_list |
| 271 | ) |
| 272 | assert any(item.author == "linkuptv" for item in report.items_by_source["instagram"]) |
| 273 | assert any( |
| 274 | item.author == "linkuptv" |
| 275 | for candidate in report.ranked_candidates |
| 276 | for item in candidate.source_items |
| 277 | ) |
| 278 |