返回 last30days-skill
test_creator_first_party.py
根目录 / tests / test_creator_first_party.py
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
278 lines PYTHON