返回 last30days-skill
x_judge.py
根目录 / skills / last30days / scripts / lib / x_judge.py
1 """X corpus judging for retrieve-judge-retry.
2
3 No I/O: judges items already retrieved. Reuses relevance.token_overlap_relevance.
4
5 The judge detects off-topic floods and determines which extracted handles should
6 be promoted to the FROM lane based on on-topic post ratio, not frequency.
7 """
8
9 from collections import Counter
10 from typing import Any, Dict, List, Optional, Set, Tuple
11
12 from . import relevance
13
14 # Minimum on-topic ratio for the overall corpus. Below this, the engine should
15 # retry with a wider keyword query. Rome measured ~0.2 (8/40 on-topic).
16 CORPUS_ON_TOPIC_FLOOR = 0.4
17
18 # Minimum on-topic ratio for a handle's posts to qualify for FROM promotion.
19 HANDLE_ON_TOPIC_FLOOR = 0.5
20
21 # Minimum on-topic keyword hits before a handle can be promoted to FROM lane.
22 # Prevents promoting handles that appeared in thin phrase hits with only 1 match.
23 MIN_ON_TOPIC_HITS = 2
24
25
26 # Ambiguous short tokens that require case-sensitive matching to avoid
27 # pronoun/acronym collisions. E.g., "US" (country) vs "us" (pronoun).
28 # For these tokens, require the text to contain the uppercase form (acronym)
29 # rather than just the lowercase form (common word).
30 _CASE_SENSITIVE_ACRONYMS = frozenset({'us'})
31
32
33 def _compute_relevance(query: str, text: str) -> float:
34 """Compute relevance score for a post against the topic query.
35
36 Returns 0.0 for empty/stopword-only queries to avoid treating all items
37 as equally relevant (the shared relevance module returns 0.5 for empty
38 queries as a neutral fallback, but x_judge needs strict filtering).
39
40 Uses case-sensitive matching for ambiguous short tokens like 'us' to
41 distinguish country acronym 'US' from pronoun 'us'.
42 """
43 import re
44 if not query or not text:
45 return 0.0
46
47 q_tokens = relevance.tokenize(query)
48 if not q_tokens:
49 return 0.0 # All query tokens were stopwords
50
51 # Check for ambiguous acronyms that need case-sensitive handling
52 t_tokens = relevance.tokenize(text)
53 filtered_q_tokens = set(q_tokens)
54 for acronym in _CASE_SENSITIVE_ACRONYMS:
55 if acronym in q_tokens and acronym in t_tokens:
56 # Text has the token, but we need to check if it's the acronym (US)
57 # or the common word (us). If text only has lowercase, don't count it.
58 has_uppercase = bool(re.search(rf'\b{acronym.upper()}\b', text))
59 has_lowercase = bool(re.search(rf'\b{acronym}\b', text))
60 if has_lowercase and not has_uppercase:
61 # Text only has lowercase version (pronoun) - don't count this
62 # token in query overlap. This effectively removes "us" from
63 # contributing to the score when text has only the pronoun.
64 t_tokens = t_tokens - {acronym}
65 # Also remove from query for this calculation to avoid
66 # penalizing the overall coverage ratio
67 filtered_q_tokens = filtered_q_tokens - {acronym}
68
69 # If filtering removed all query tokens, fall back to 0
70 if not filtered_q_tokens:
71 return 0.0
72
73 overlap_tokens = filtered_q_tokens & t_tokens
74 if not overlap_tokens:
75 return 0.0
76
77 # Compute simplified relevance: coverage ratio
78 # This is a simpler version of token_overlap_relevance that uses
79 # the filtered tokens rather than re-tokenizing the original text
80 coverage = len(overlap_tokens) / len(filtered_q_tokens)
81 return coverage
82
83
84 def judge_x_corpus(
85 items: List[Dict[str, Any]],
86 topic: str,
87 *,
88 ranking_query: str = "",
89 ) -> Dict[str, Any]:
90 """Judge the retrieved X corpus for on-topic ratio.
91
92 Args:
93 items: List of X items with 'author_handle' and 'text' fields
94 topic: The user topic (e.g., "Rome")
95 ranking_query: Optional ranking query for better relevance scoring
96
97 Returns:
98 Dict with:
99 - on_topic_ratio: float, fraction of posts that are on-topic
100 - is_off_topic_flood: bool, True if corpus fails the on-topic floor
101 - on_topic_items: list, items that passed relevance check
102 - off_topic_items: list, items that failed relevance check
103 - handle_stats: dict, per-handle on-topic counts and totals
104 """
105 if not items:
106 return {
107 "on_topic_ratio": 1.0,
108 "is_off_topic_flood": False,
109 "on_topic_items": [],
110 "off_topic_items": [],
111 "handle_stats": {},
112 }
113
114 # Use ranking_query if provided, otherwise topic
115 query = ranking_query or topic
116
117 on_topic_items = []
118 off_topic_items = []
119 handle_stats: Dict[str, Dict[str, int]] = {}
120
121 for item in items:
122 handle = (item.get("author_handle") or "").lower()
123 text = item.get("text") or ""
124 score = _compute_relevance(query, text)
125
126 # On-topic threshold: relevance.RELEVANCE_FLOOR is 0.1
127 is_on_topic = score >= relevance.RELEVANCE_FLOOR
128
129 if is_on_topic:
130 on_topic_items.append(item)
131 else:
132 off_topic_items.append(item)
133
134 if handle:
135 if handle not in handle_stats:
136 handle_stats[handle] = {"on_topic": 0, "total": 0}
137 handle_stats[handle]["total"] += 1
138 if is_on_topic:
139 handle_stats[handle]["on_topic"] += 1
140
141 on_topic_ratio = len(on_topic_items) / len(items) if items else 1.0
142
143 # Check if top-3 frequency authors have poor on-topic ratio
144 top_handles = sorted(
145 handle_stats.items(),
146 key=lambda x: x[1]["total"],
147 reverse=True,
148 )[:3]
149 top_authors_off_topic = all(
150 stats["on_topic"] / stats["total"] < HANDLE_ON_TOPIC_FLOOR
151 for _, stats in top_handles
152 if stats["total"] > 0
153 ) if top_handles else False
154
155 is_off_topic_flood = (
156 on_topic_ratio < CORPUS_ON_TOPIC_FLOOR
157 or (top_authors_off_topic and len(on_topic_items) < MIN_ON_TOPIC_HITS)
158 )
159
160 return {
161 "on_topic_ratio": on_topic_ratio,
162 "is_off_topic_flood": is_off_topic_flood,
163 "on_topic_items": on_topic_items,
164 "off_topic_items": off_topic_items,
165 "handle_stats": handle_stats,
166 }
167
168
169 def promotable_handles(
170 items: List[Dict[str, Any]],
171 topic: str,
172 extracted_handles: List[str],
173 *,
174 explicit_handles: Optional[List[str]] = None,
175 ranking_query: str = "",
176 ) -> Tuple[List[str], List[str]]:
177 """Determine which handles should be promoted to the FROM lane.
178
179 Split FROM promotion:
180 - Explicit handles (--x-handle/--x-related): always promoted, no AND topic
181 - Extracted handles: promoted only if:
182 - ≥MIN_ON_TOPIC_HITS on-topic keyword hits AND
183 - author on-topic ratio ≥ HANDLE_ON_TOPIC_FLOOR
184 - These pulls AND the topic (from:handle Rome)
185
186 Args:
187 items: List of X items with 'author_handle' and 'text' fields
188 topic: The user topic
189 extracted_handles: Handles extracted from entity_extract
190 explicit_handles: Explicit --x-handle/--x-related handles
191 ranking_query: Optional ranking query for relevance scoring
192
193 Returns:
194 Tuple of (explicit_promotable, extracted_promotable):
195 - explicit_promotable: handles that get FROM without AND topic
196 - extracted_promotable: handles that get FROM with AND topic
197 """
198 explicit_set = {
199 h.lower().lstrip("@")
200 for h in (explicit_handles or [])
201 if h and h.strip()
202 }
203
204 # Judge corpus to get handle stats
205 judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
206 handle_stats = judgment["handle_stats"]
207
208 explicit_promotable = []
209 extracted_promotable = []
210
211 for handle in extracted_handles:
212 handle_lower = handle.lower().lstrip("@")
213
214 # Explicit handles always promoted (no AND topic)
215 if handle_lower in explicit_set:
216 explicit_promotable.append(handle)
217 continue
218
219 # Check if handle qualifies for extracted promotion
220 stats = handle_stats.get(handle_lower, {"on_topic": 0, "total": 0})
221
222 # Need ≥MIN_ON_TOPIC_HITS on-topic posts
223 if stats["on_topic"] < MIN_ON_TOPIC_HITS:
224 continue
225
226 # Need ≥HANDLE_ON_TOPIC_FLOOR ratio
227 if stats["total"] > 0:
228 ratio = stats["on_topic"] / stats["total"]
229 if ratio >= HANDLE_ON_TOPIC_FLOOR:
230 extracted_promotable.append(handle)
231
232 # Also check explicit handles not in extracted list
233 for handle in (explicit_handles or []):
234 handle_lower = handle.lower().lstrip("@")
235 if handle_lower not in [h.lower() for h in explicit_promotable]:
236 if handle_lower not in [h.lower() for h in extracted_handles]:
237 explicit_promotable.append(handle)
238
239 return explicit_promotable, extracted_promotable
240
241
242 def should_retry_x_search(
243 items: List[Dict[str, Any]],
244 topic: str,
245 *,
246 ranking_query: str = "",
247 depth: str = "default",
248 ) -> bool:
249 """Determine if X search should retry with wider keyword query.
250
251 Skip retry on quick/mock (same as Phase 2).
252
253 Args:
254 items: Retrieved X items
255 topic: The user topic
256 ranking_query: Optional ranking query
257 depth: Search depth ("quick", "default", "deep")
258
259 Returns:
260 True if retry is warranted
261 """
262 if depth == "quick":
263 return False
264
265 if not items:
266 return False # Nothing to judge, no retry
267
268 judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
269 return judgment["is_off_topic_flood"]
270
271
272 def prune_off_topic_items(
273 items: List[Dict[str, Any]],
274 topic: str,
275 *,
276 ranking_query: str = "",
277 ) -> List[Dict[str, Any]]:
278 """Prune off-topic items before the pool.
279
280 Eight on-topic items with 32 pruned → ok with 8.
281 Zero on-topic → no-results, not ok with 40 junk.
282
283 Args:
284 items: Retrieved X items
285 topic: The user topic
286 ranking_query: Optional ranking query
287
288 Returns:
289 Only on-topic items
290 """
291 judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
292 return judgment["on_topic_items"]
293
293 lines PYTHON