返回 last30days-skill
fusion.py
根目录 / skills / last30days / scripts / lib / fusion.py
1 """Weighted reciprocal rank fusion for per-(subquery, source) streams."""
2
3 from __future__ import annotations
4
5 from collections.abc import Iterable
6
7 from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
8
9 from . import schema
10
11 # Standard RRF smoothing constant (Cormack et al. 2009)
12 RRF_K = 60
13
14
15 def _candidate_sort_key(c: schema.Candidate) -> tuple:
16 # Out-of-window evidence sorts strictly below anything in the window. A
17 # "last 30 days" brief that ranks a nine-month-old video at #1 breaks its
18 # own contract, however relevant that video is; it still appears, just
19 # never above in-window evidence.
20 return (
21 1 if schema.candidate_out_of_window(c) else 0,
22 -c.rrf_score,
23 -c.local_relevance,
24 -c.freshness,
25 schema.candidate_source_label(c),
26 c.title,
27 )
28
29
30 def _normalize_url(url: str) -> str:
31 """Normalize URL for dedup: lowercase, strip www/old/m prefixes, remove tracking params."""
32 parsed = urlparse(url.strip().lower())
33 netloc = parsed.netloc
34 for prefix in ("www.", "old.", "m."):
35 if netloc.startswith(prefix):
36 netloc = netloc[len(prefix):]
37 # Strip tracking params
38 params = parse_qs(parsed.query)
39 clean_params = {k: v for k, v in params.items() if not k.startswith("utm_")}
40 query = urlencode(clean_params, doseq=True)
41 return urlunparse((parsed.scheme, netloc, parsed.path.rstrip("/"), "", query, ""))
42
43
44 def candidate_key(item: schema.SourceItem) -> str:
45 if item.url:
46 return _normalize_url(item.url)
47 return f"{item.source}:{item.item_id}"
48
49
50 # Enrichment that one copy of a thread may carry and another may lack. When
51 # the same URL arrives from two subquery streams (each stream enriches its own
52 # top-N), the copy that won a comment slot must survive de-duplication.
53 _ENRICHMENT_KEYS = (
54 "top_comments",
55 "comment_insights",
56 "transcript_highlights",
57 "transcript_snippet",
58 "transcript",
59 )
60
61
62 def merge_source_items(existing: schema.SourceItem, incoming: schema.SourceItem) -> schema.SourceItem:
63 """Fold ``incoming``'s enrichment and counts into ``existing`` (same thread).
64
65 Keeps the richer value per field: an enrichment list the existing copy
66 lacks (or a longer one), the larger numeric engagement counters, and the
67 longer body/snippet. Mutates and returns ``existing``.
68 """
69 for key in _ENRICHMENT_KEYS:
70 theirs = incoming.metadata.get(key)
71 if not theirs:
72 continue
73 mine = existing.metadata.get(key)
74 if not mine or (isinstance(theirs, list) and isinstance(mine, list) and len(theirs) > len(mine)):
75 existing.metadata[key] = theirs
76 for field_name, value in (incoming.engagement or {}).items():
77 if isinstance(value, bool) or not isinstance(value, (int, float)):
78 if existing.engagement.get(field_name) is None and value is not None:
79 existing.engagement[field_name] = value
80 continue
81 current = existing.engagement.get(field_name)
82 if not isinstance(current, (int, float)) or isinstance(current, bool) or value > current:
83 existing.engagement[field_name] = value
84 if len(incoming.body or "") > len(existing.body or ""):
85 existing.body = incoming.body
86 if len(incoming.snippet or "") > len(existing.snippet or ""):
87 existing.snippet = incoming.snippet
88 return existing
89
90
91 def collapse_duplicate_urls(items: list[schema.SourceItem]) -> list[schema.SourceItem]:
92 """Collapse same-source, same-URL copies, keeping order and merging enrichment.
93
94 Per-stream item ids (``R1``, ``X1``) collide across subqueries, so identity
95 is the normalized URL, not the id. The first occurrence stays in place and
96 absorbs later copies via :func:`merge_source_items`.
97 """
98 first_by_key: dict[tuple[str, str], schema.SourceItem] = {}
99 kept: list[schema.SourceItem] = []
100 for item in items:
101 key = (item.source, candidate_key(item))
102 existing = first_by_key.get(key)
103 if existing is None:
104 first_by_key[key] = item
105 kept.append(item)
106 else:
107 merge_source_items(existing, item)
108 return kept
109
110
111 _DIVERSITY_RELEVANCE_THRESHOLD = 0.25
112
113 # Reddit engagement reservation: pool slots held for the highest-engagement
114 # entity-grounded in-window Reddit candidates, scaled by pool size (quick 15
115 # -> 2, default 40 -> 3, deep 60 -> 4). The fused order is RRF-first and each
116 # stream is relevance-first, so the month's most-discussed on-topic thread can
117 # otherwise lose its slot to a one-upvote post with better title overlap.
118 _REDDIT_RESERVE_BY_POOL = ((15, 2), (40, 3))
119 _REDDIT_RESERVE_MAX = 4
120
121
122 def _reddit_reserve_for(pool_limit: int) -> int:
123 for ceiling, reserve in _REDDIT_RESERVE_BY_POOL:
124 if pool_limit <= ceiling:
125 return reserve
126 return _REDDIT_RESERVE_MAX
127
128
129 def relevance_floor_for_entity(entity: str) -> float:
130 """Relevance a Reddit thread must clear to earn a reservation or keeper slot.
131
132 Grounding keys on the entity's head token (see ``rerank._entity_grounded``).
133 A generic head ("ai", "x", "new") matches almost anything, so the floor
134 rises to the diversity threshold; a distinctive head keeps the shared
135 ``RELEVANCE_FLOOR``.
136 """
137 from . import relevance
138
139 head = (entity or "").lower().split()[:1]
140 if not head:
141 return relevance.RELEVANCE_FLOOR
142 token = head[0]
143 generic = (
144 len(token) <= 2
145 or token in relevance.STOPWORDS
146 or token in relevance.LOW_SIGNAL_QUERY_TOKENS
147 )
148 return _DIVERSITY_RELEVANCE_THRESHOLD if generic else relevance.RELEVANCE_FLOOR
149
150
151 def raw_engagement(item: schema.SourceItem) -> float:
152 """Upvotes plus comments as a plain number, for engagement-first ordering."""
153 eng = item.engagement or {}
154 total = 0.0
155 for key in ("score", "num_comments"):
156 value = eng.get(key)
157 if isinstance(value, (int, float)) and not isinstance(value, bool):
158 total += float(value)
159 return total
160
161
162 def reddit_thread_qualifies(
163 item: schema.SourceItem,
164 entity: str,
165 floor: float,
166 relevance: float | None = None,
167 ) -> bool:
168 """On-topic enough for an engagement slot: clears the floor and names the entity.
169
170 ``relevance`` overrides the item's own ``local_relevance`` (a fused
171 candidate carries the max across its copies).
172 """
173 from . import rerank
174
175 score = relevance if relevance is not None else (item.local_relevance or 0.0)
176 if score < floor:
177 return False
178 if raw_engagement(item) <= 0:
179 return False
180 if not entity:
181 return True
182 return rerank._entity_grounded(f"{item.title or ''} {item.body or ''}", entity)
183
184 # Per-author cap: no single author/handle should dominate the pool.
185 _MAX_ITEMS_PER_AUTHOR = 3
186
187 # Raised cap for the subject of the topic (a handle in the run's
188 # resolved_handles). On a person or company topic the subject is what the user
189 # asked about, so the flat cap discards exactly the evidence the run worked
190 # hardest to retrieve -- the measured 'Peter Steinberger steipete' baseline
191 # recovered 8 subject-authored posts and would have kept 3. Still bounded: a
192 # prolific subject must not crowd out commentary about them, which is the other
193 # half of the answer a user wants.
194 _MAX_ITEMS_PER_FIRST_PARTY_AUTHOR = 8
195
196
197 def _extract_author(candidate: schema.Candidate) -> str | None:
198 """Return a normalized author key from a candidate's source items."""
199 for item in candidate.source_items:
200 if item.author:
201 return item.author.strip().lower()
202 return None
203
204
205 def _apply_per_author_cap(
206 candidates: list[schema.Candidate],
207 max_per_author: int = _MAX_ITEMS_PER_AUTHOR,
208 first_party_handles: Iterable[str] | None = None,
209 max_per_first_party_author: int = _MAX_ITEMS_PER_FIRST_PARTY_AUTHOR,
210 ) -> list[schema.Candidate]:
211 """Keep at most *max_per_author* items from any single author.
212
213 Authors named in *first_party_handles* -- the subject of the topic -- get
214 the higher *max_per_first_party_author* allowance instead, because their
215 own posts are the point of the query rather than one voice among many.
216
217 Candidates are assumed to already be sorted by quality (rrf_score etc.),
218 so the first N encountered per author are the best ones.
219 """
220 first_party = {
221 h.strip().lstrip("@").lower()
222 for h in (first_party_handles or ())
223 if h and h.strip()
224 }
225 author_counts: dict[str, int] = {}
226 result: list[schema.Candidate] = []
227 for c in candidates:
228 author = _extract_author(c)
229 if author is None:
230 result.append(c)
231 continue
232 limit = (
233 max_per_first_party_author
234 if author.strip().lstrip("@").lower() in first_party
235 else max_per_author
236 )
237 count = author_counts.get(author, 0)
238 if count < limit:
239 result.append(c)
240 author_counts[author] = count + 1
241 return result
242
243
244 def _reddit_engagement_reservation(
245 fused: list[schema.Candidate],
246 reserve: int,
247 entity: str,
248 ) -> list[schema.Candidate]:
249 """The *reserve* highest-engagement Reddit candidates that are in-window
250 and on-topic, in engagement order."""
251 if reserve <= 0:
252 return []
253 floor = relevance_floor_for_entity(entity)
254 eligible = []
255 for c in fused:
256 if c.source != "reddit" or schema.candidate_out_of_window(c):
257 continue
258 reddit_items = [it for it in c.source_items if it.source == "reddit"]
259 if not reddit_items:
260 continue
261 best = max(reddit_items, key=raw_engagement)
262 relevance = max(c.local_relevance or 0.0, best.local_relevance or 0.0)
263 if not reddit_thread_qualifies(best, entity, floor, relevance=relevance):
264 continue
265 eligible.append((raw_engagement(best), c))
266 eligible.sort(key=lambda pair: -pair[0])
267 return [c for _, c in eligible[:reserve]]
268
269
270 def _diversify_pool(
271 fused: list[schema.Candidate],
272 pool_limit: int,
273 min_per_source: int = 2,
274 entity: str = "",
275 ) -> list[schema.Candidate]:
276 """Ensure at least *min_per_source* items per qualifying source survive truncation.
277
278 Sources only qualify for reserved slots if their best item exceeds
279 the relevance threshold. Low-relevance sources compete on merit only.
280 Reddit additionally gets an engagement reservation (see
281 ``_reddit_reserve_for``) for its most-discussed on-topic threads.
282 """
283 max_relevance: dict[str, float] = {}
284 for c in fused:
285 current = max_relevance.get(c.source, 0.0)
286 if c.local_relevance > current:
287 max_relevance[c.source] = c.local_relevance
288
289 protected = _reddit_engagement_reservation(fused, _reddit_reserve_for(pool_limit), entity)
290 protected_ids = {c.candidate_id for c in protected}
291 pool: list[schema.Candidate] = list(protected)
292 seen = set(protected_ids)
293 reserved: dict[str, list[schema.Candidate]] = {}
294 remainder: list[schema.Candidate] = []
295 for c in fused:
296 if c.candidate_id in seen:
297 continue
298 qualifies = max_relevance.get(c.source, 0.0) >= _DIVERSITY_RELEVANCE_THRESHOLD
299 bucket = reserved.setdefault(c.source, [])
300 if qualifies and len(bucket) < min_per_source:
301 bucket.append(c)
302 else:
303 remainder.append(c)
304 pool.extend(c for per_source in reserved.values() for c in per_source)
305 seen = {c.candidate_id for c in pool}
306 for c in remainder:
307 if len(pool) >= pool_limit:
308 break
309 if c.candidate_id not in seen:
310 pool.append(c)
311 pool.sort(key=_candidate_sort_key)
312 if len(pool) > pool_limit:
313 # The per-source buckets can overfill a small pool. The Reddit
314 # reservation is low-RRF by construction, so a plain slice would cut
315 # exactly the threads it exists to keep: trim unprotected candidates
316 # from the sorted tail instead.
317 keep_unprotected = pool_limit - len(protected_ids)
318 trimmed: list[schema.Candidate] = []
319 for c in pool:
320 if c.candidate_id in protected_ids:
321 trimmed.append(c)
322 elif keep_unprotected > 0:
323 trimmed.append(c)
324 keep_unprotected -= 1
325 pool = trimmed
326 return pool[:pool_limit]
327
328
329 def weighted_rrf(
330 streams: dict[tuple[str, str], list[schema.SourceItem]],
331 plan: schema.QueryPlan,
332 *,
333 pool_limit: int,
334 range_from: str | None = None,
335 range_to: str | None = None,
336 first_party_handles: Iterable[str] | None = None,
337 ) -> list[schema.Candidate]:
338 """Fuse ranked lists into a single candidate pool.
339
340 When ``range_from`` and ``range_to`` are provided, they are stored in each
341 candidate's metadata so ``candidate_out_of_window`` can compare the actual
342 date against the run window (instead of relying solely on adapter-provided
343 ``date_confidence``). ``first_party_handles`` raises the per-author cap
344 for the topic's subject so their own posts are not flattened to the
345 incidental-account allowance.
346 """
347 subqueries = {subquery.label: subquery for subquery in plan.subqueries}
348 candidates: dict[str, schema.Candidate] = {}
349 # Track source items already attached to each candidate, keyed by
350 # (source, normalized URL): per-stream ids collide across subqueries, and
351 # a repeat copy may carry enrichment the first one lacks.
352 seen_source_items: dict[str, dict[tuple[str, str], schema.SourceItem]] = {}
353
354 for (label, source), items in streams.items():
355 subquery = subqueries[label]
356 weight = subquery.weight * plan.source_weights.get(source, 1.0)
357 for rank, item in enumerate(items, start=1):
358 key = candidate_key(item)
359 score = weight / (RRF_K + rank)
360 item_local_relevance = item.local_relevance if item.local_relevance is not None else float(item.metadata.get("local_relevance", item.relevance_hint))
361 item_freshness = item.freshness if item.freshness is not None else int(item.metadata.get("freshness", 0))
362 item_source_quality = item.source_quality if item.source_quality is not None else float(item.metadata.get("source_quality", 0.6))
363 if key not in candidates:
364 candidate_metadata: dict = {
365 "provenance": [
366 {
367 "source": source,
368 "subquery_label": label,
369 "native_rank": rank,
370 "item_id": item.item_id,
371 }
372 ]
373 }
374 if range_from:
375 candidate_metadata["range_from"] = range_from
376 if range_to:
377 candidate_metadata["range_to"] = range_to
378 candidates[key] = schema.Candidate(
379 candidate_id=key,
380 item_id=item.item_id,
381 source=item.source,
382 title=item.title,
383 url=item.url,
384 snippet=item.snippet,
385 subquery_labels=[label],
386 native_ranks={f"{label}:{source}": rank},
387 local_relevance=item_local_relevance,
388 freshness=item_freshness,
389 engagement=item.engagement_score if item.engagement_score is not None else item.metadata.get("engagement_score"),
390 source_quality=item_source_quality,
391 rrf_score=score,
392 sources=[item.source],
393 source_items=[item],
394 metadata=candidate_metadata,
395 )
396 seen_source_items[key] = {(item.source, candidate_key(item)): item}
397 continue
398
399 candidate = candidates[key]
400 candidate.rrf_score += score
401 previous_primary_score = (candidate.local_relevance * 100.0) + candidate.freshness + (candidate.source_quality * 10.0)
402 incoming_primary_score = (item_local_relevance * 100.0) + item_freshness + (item_source_quality * 10.0)
403 candidate.local_relevance = max(
404 candidate.local_relevance,
405 item_local_relevance,
406 )
407 candidate.freshness = max(candidate.freshness, item_freshness)
408 item_eng = item.engagement_score if item.engagement_score is not None else item.metadata.get("engagement_score")
409 if candidate.engagement is None:
410 candidate.engagement = item_eng
411 elif item_eng is not None:
412 candidate.engagement = max(candidate.engagement, item_eng)
413 candidate.source_quality = max(
414 candidate.source_quality,
415 item_source_quality,
416 )
417 candidate.native_ranks[f"{label}:{source}"] = rank
418 if label not in candidate.subquery_labels:
419 candidate.subquery_labels.append(label)
420 if item.source not in candidate.sources:
421 candidate.sources.append(item.source)
422 source_item_key = (item.source, candidate_key(item))
423 existing_item = seen_source_items[key].get(source_item_key)
424 if existing_item is None:
425 seen_source_items[key][source_item_key] = item
426 candidate.source_items.append(item)
427 else:
428 merge_source_items(existing_item, item)
429 candidate.metadata.setdefault("provenance", []).append(
430 {
431 "source": source,
432 "subquery_label": label,
433 "native_rank": rank,
434 "item_id": item.item_id,
435 }
436 )
437 if incoming_primary_score > previous_primary_score:
438 candidate.item_id = item.item_id
439 candidate.source = item.source
440 candidate.title = item.title
441 candidate.snippet = item.snippet
442 if len(candidate.snippet.split()) < len(item.snippet.split()):
443 candidate.snippet = item.snippet
444
445 fused = sorted(candidates.values(), key=_candidate_sort_key)
446 fused = _apply_per_author_cap(fused, first_party_handles=first_party_handles)
447 from . import rerank
448
449 entity = rerank._primary_entity(plan.raw_topic or "") if plan.raw_topic else ""
450 return _diversify_pool(fused, pool_limit, entity=entity)
451
451 lines PYTHON