返回 last30days-skill
quality_nudge.py
根目录 / skills / last30days / scripts / lib / quality_nudge.py
1 """Post-research quality score and upgrade nudge.
2
3 Computes a quality score based on 5 core sources and builds
4 a nudge message describing what the user missed and how to fix it.
5
6 Fix text comes from ``lib.prescriptions`` (the single remediation
7 vocabulary shared with the doctor command, KTD 7); only the trigger
8 logic and the message framing live here.
9 """
10
11 from typing import List
12
13 from . import prescriptions
14
15
16 # The 5 core sources
17 CORE_SOURCES = ["hn", "polymarket", "x", "youtube", "reddit"]
18
19 # Labels for display
20 SOURCE_LABELS = {
21 "hn": "Hacker News",
22 "polymarket": "Polymarket",
23 "x": "X/Twitter",
24 "youtube": "YouTube",
25 "reddit": "Reddit",
26 }
27
28
29 def _is_x_active(config: dict, research_results: dict) -> bool:
30 """Check if X source is active (has credentials AND didn't error)."""
31 has_creds = _has_x_credentials(config)
32 if not has_creds:
33 return False
34 # If X errored this run, it's configured but broken
35 if research_results.get("x_error"):
36 return False
37 return True
38
39
40 def _has_x_credentials(config: dict) -> bool:
41 """Return True when any X/Twitter source credential is configured."""
42 return bool(
43 config.get("AUTH_TOKEN")
44 or config.get("XAI_API_KEY")
45 or config.get("XQUIK_API_KEY")
46 )
47
48
49 def _has_ytdlp() -> bool:
50 """Return True when the local/free YouTube lane is available."""
51 try:
52 from . import youtube_yt
53 return bool(youtube_yt.is_ytdlp_installed())
54 except Exception:
55 return False
56
57
58 def _youtube_returned_data(research_results: dict) -> bool:
59 """Return True when YouTube produced usable items through any provider."""
60 videos = int(research_results.get("youtube_videos_count") or 0)
61 transcripts = int(research_results.get("youtube_transcripts_count") or 0)
62 return videos > 0 or transcripts > 0
63
64
65 def _is_youtube_active(config: dict, research_results: dict, *, has_ytdlp: bool) -> bool:
66 """Check if YouTube source is active (yt-dlp installed)."""
67 if not has_ytdlp:
68 return False
69 if research_results.get("youtube_error"):
70 return False
71 return True
72
73
74 # Below this transcript-fetch ratio, YouTube is considered "degraded" rather
75 # than active. Picked at 50% so a single legitimate caption-disabled video in a
76 # multi-video result does not trip the nudge, but a stale-yt-dlp run that fails
77 # every transcript does. Tunable via DEGRADED_TRANSCRIPT_THRESHOLD env var if
78 # operators need to adjust without code changes.
79 DEFAULT_DEGRADED_TRANSCRIPT_THRESHOLD = 0.5
80
81
82 def _is_youtube_degraded(research_results: dict, threshold: float) -> bool:
83 """YouTube is degraded when videos were returned but the transcript-fetch
84 ratio is below threshold. The canonical cause is a stale yt-dlp binary -
85 YouTube's caption format changes frequently and old binaries silently fail
86 every transcript while the search itself still succeeds.
87
88 Captions-disabled videos are subtracted from the denominator: an uploader
89 who turned off captions can never produce a transcript, so counting that
90 video toward "fetch failures" produces false positives. A single
91 captions-disabled video in a small result set was tripping the nudge.
92
93 When actual fetch outcomes are available, they take precedence over the
94 post-pruning ratio: the report counts only see items that survived
95 freshness/relevance pruning, so a run where every transcript fetch
96 succeeded but the fetched videos were later pruned looks identical to a
97 stale-binary run (#531). Zero failures across attempted fetches proves
98 the binary works - don't flag.
99 """
100 videos = int(research_results.get("youtube_videos_count") or 0)
101 transcripts = int(research_results.get("youtube_transcripts_count") or 0)
102 captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
103 if videos <= 0:
104 return False
105 fetch_attempts = int(research_results.get("youtube_transcript_fetch_attempts") or 0)
106 fetch_failures = int(research_results.get("youtube_transcript_fetch_failures") or 0)
107 if fetch_attempts > 0 and fetch_failures == 0:
108 return False
109 eligible = videos - captions_disabled
110 if eligible <= 0:
111 # Every returned video had captions disabled - upstream content fact,
112 # not a yt-dlp problem. Don't flag.
113 return False
114 return (transcripts / eligible) < threshold
115
116
117 def _is_instagram_silent_failure(config: dict, research_results: dict) -> bool:
118 """Instagram is silently failing when SC is configured but the source
119 returned zero items. The canonical cause is SC's v2 reels endpoint
120 500'ing on multi-token queries (it wraps Google Search and is documented
121 to be flaky there). Pre-fix the user got no signal at all - no Instagram
122 section in the brief, no error in the footer, just unexplained absence.
123 """
124 if not config.get("SCRAPECREATORS_API_KEY"):
125 return False # not configured — not a silent failure
126 # Honor EXCLUDE_SOURCES: a user who set EXCLUDE_SOURCES=instagram
127 # intentionally turned the source off, so a zero-item count is
128 # expected, not a silent failure. Mirror the canonical parsing
129 # pattern from pipeline.available_sources().
130 excluded = {
131 s.strip().lower()
132 for s in (config.get("EXCLUDE_SOURCES") or "").split(",")
133 if s.strip()
134 }
135 # Symmetric case: INCLUDE_SOURCES is an opt-in allowlist. If it is
136 # non-empty and does not name instagram, the source was intentionally
137 # filtered out, so a zero-item count is expected — not a silent failure.
138 included = {
139 s.strip().lower()
140 for s in (config.get("INCLUDE_SOURCES") or "").split(",")
141 if s.strip()
142 }
143 if "instagram" in excluded or (included and "instagram" not in included):
144 return False
145 count = research_results.get("instagram_items_count")
146 if count is None:
147 return False # source not run this invocation
148 return int(count) == 0
149
150
151 def compute_quality_score(config: dict, research_results: dict) -> dict:
152 """Compute research quality score based on 5 core sources.
153
154 Args:
155 config: Configuration dict from env.get_config()
156 research_results: Dict with keys like x_error, youtube_error,
157 reddit_error reflecting what happened this run. Optional keys
158 ``youtube_videos_count`` and ``youtube_transcripts_count`` enable
159 degraded-YouTube detection (transcript-fetch ratio below threshold,
160 or fallback/provider data returned without local yt-dlp).
161 Optional key ``instagram_items_count`` enables silent-failure
162 detection for the bonus Instagram source.
163
164 Returns:
165 {
166 "score_pct": 40-100,
167 "core_active": ["hn", "polymarket", ...],
168 "core_missing": ["x", "youtube"],
169 "core_errored": [], # configured but errored at top level
170 "core_degraded": [], # configured and returned items but quality below threshold
171 "bonus_errored": [], # bonus sources (Instagram, etc.) configured but silent
172 "nudge_text": "..." or None if all sources healthy
173 }
174 """
175 core_active: List[str] = []
176 core_missing: List[str] = []
177 core_errored: List[str] = []
178 core_degraded: List[str] = []
179 bonus_errored: List[str] = []
180
181 # HN, Polymarket, and Reddit are always active
182 core_active.append("hn")
183 core_active.append("polymarket")
184 core_active.append("reddit")
185
186 # X
187 has_x_creds = _has_x_credentials(config)
188 if _is_x_active(config, research_results):
189 core_active.append("x")
190 else:
191 core_missing.append("x")
192 if has_x_creds and research_results.get("x_error"):
193 core_errored.append("x")
194
195 # YouTube
196 has_ytdlp = _has_ytdlp()
197 yt_active = _is_youtube_active(config, research_results, has_ytdlp=has_ytdlp)
198 youtube_returned_data = _youtube_returned_data(research_results)
199 if yt_active:
200 core_active.append("youtube")
201 # Active means yt-dlp is installed and search did not error at the top
202 # level. But search-success + transcript-failure is the canonical
203 # stale-binary failure mode that the footer used to hide. Flag as
204 # degraded so the user gets an actionable nudge to update the binary.
205 threshold = float(config.get("DEGRADED_TRANSCRIPT_THRESHOLD") or DEFAULT_DEGRADED_TRANSCRIPT_THRESHOLD)
206 if _is_youtube_degraded(research_results, threshold):
207 core_degraded.append("youtube")
208 elif youtube_returned_data and not research_results.get("youtube_error"):
209 # YouTube produced data through a fallback/provider lane even though the
210 # local free yt-dlp lane is unavailable. Count the source as present,
211 # but surface it as degraded so users do not see the contradictory
212 # "Missing: YouTube" ending after a report with YouTube evidence.
213 # has_ytdlp is provably False here: yt_active is False and youtube_error
214 # is excluded by this guard, leaving unavailable yt-dlp as the cause.
215 core_active.append("youtube")
216 core_degraded.append("youtube")
217 else:
218 core_missing.append("youtube")
219 # Check if configured but errored (yt-dlp installed but failed this run)
220 if has_ytdlp and research_results.get("youtube_error"):
221 core_errored.append("youtube")
222
223 # Bonus sources (Instagram, etc.): SC-key holders expect content from
224 # these but until now the pipeline fell silent on configured-but-zero.
225 if _is_instagram_silent_failure(config, research_results):
226 bonus_errored.append("instagram")
227
228 score_pct = int(len(core_active) / 5 * 100)
229
230 has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
231 active_sources = research_results.get("active_sources") or []
232 nudge_text = _build_nudge_text(
233 core_missing,
234 core_errored,
235 core_degraded,
236 research_results,
237 has_sc=has_sc,
238 active_sources=active_sources,
239 bonus_errored=bonus_errored,
240 has_ytdlp=has_ytdlp,
241 ) if (core_missing or core_degraded or bonus_errored) else None
242
243 return {
244 "score_pct": score_pct,
245 "core_active": core_active,
246 "core_missing": core_missing,
247 "core_errored": core_errored,
248 "core_degraded": core_degraded,
249 "bonus_errored": bonus_errored,
250 "nudge_text": nudge_text,
251 }
252
253
254 def _build_nudge_text(
255 core_missing: List[str],
256 core_errored: List[str],
257 core_degraded: List[str] = None,
258 research_results: dict = None,
259 has_sc: bool = False,
260 active_sources: list = None,
261 bonus_errored: List[str] = None,
262 has_ytdlp: bool = False,
263 ) -> str:
264 """Build human-readable nudge text describing what was missed or degraded.
265
266 Prioritizes free suggestions. Optionally mentions bonus sources
267 (TikTok, Instagram, Threads, Pinterest) if ScrapeCreators key is configured.
268 """
269 lines: List[str] = []
270 core_degraded = core_degraded or []
271 bonus_errored = bonus_errored or []
272 research_results = research_results or {}
273
274 # Describe what was missed
275 missed_parts: List[str] = []
276 for src in core_missing:
277 label = SOURCE_LABELS[src]
278 if src in core_errored:
279 missed_parts.append(f"{label} (errored this run)")
280 else:
281 missed_parts.append(label)
282
283 active_count = 5 - len(core_missing)
284 lines.append(f"Research quality: {active_count}/5 core sources.")
285 if missed_parts:
286 lines.append(f"Missing: {', '.join(missed_parts)}.")
287 if core_degraded:
288 degraded_labels = ", ".join(SOURCE_LABELS[s] for s in core_degraded)
289 lines.append(f"Degraded: {degraded_labels}.")
290 if bonus_errored:
291 bonus_labels = ", ".join(s.capitalize() for s in bonus_errored)
292 lines.append(f"Bonus source silent: {bonus_labels}.")
293 lines.append("")
294
295 # Free suggestions
296 free_suggestions: List[str] = []
297
298 if "x" in core_missing:
299 if "x" in core_errored:
300 x_fix = prescriptions.get("x", "cookies_expired")
301 free_suggestions.append(f"X/Twitter errored - {x_fix.fix_nl}.")
302 else:
303 x_fix = prescriptions.get("x", "cookies_missing")
304 free_suggestions.append(
305 "X/Twitter: real-time posts with likes and reposts - the fastest "
306 f"signal for breaking topics. Three options: {x_fix.fix_nl}."
307 )
308
309 if "youtube" in core_missing:
310 if "youtube" in core_errored:
311 yt_fix = prescriptions.get("youtube", "ytdlp_stale")
312 free_suggestions.append(
313 f"YouTube errored - update yt-dlp: {yt_fix.fix_cli}"
314 )
315 else:
316 yt_fix = prescriptions.get("youtube", "ytdlp_missing")
317 free_suggestions.append(
318 "YouTube: video transcripts with key moments - often the deepest "
319 f"explanations on any topic. Install yt-dlp: {yt_fix.fix_cli} (free)"
320 )
321
322 if "youtube" in core_degraded:
323 videos = int(research_results.get("youtube_videos_count") or 0)
324 transcripts = int(research_results.get("youtube_transcripts_count") or 0)
325 captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
326 if not has_ytdlp and _youtube_returned_data(research_results):
327 install = prescriptions.get("youtube", "ytdlp_missing")
328 # Tolerant lookup: alt_cli makes no arity promise, so an entry
329 # gaining/losing a platform alternate must degrade the wording,
330 # never crash the nudge path.
331 scoop_install = install.alt_cli[0] if len(install.alt_cli) > 0 else install.fix_cli
332 pip_install = install.alt_cli[1] if len(install.alt_cli) > 1 else scoop_install
333 free_suggestions.append(
334 f"YouTube returned {videos} videos and {transcripts} transcripts "
335 "through a fallback/provider path, but local yt-dlp is not "
336 "installed. Install yt-dlp to enable the free local YouTube lane "
337 f"and reduce reliance on fallback providers: {install.fix_cli} "
338 f"(macOS), {scoop_install} (Windows), or {pip_install}."
339 )
340 else:
341 captions_note = ""
342 if captions_disabled > 0:
343 captions_note = (
344 f" ({captions_disabled} of those had captions disabled by the "
345 "uploader, which is a separate cause and not fixable on your end)"
346 )
347 update = prescriptions.get("youtube", "ytdlp_stale")
348 # Same tolerant lookup as the install branch above.
349 scoop_update = update.alt_cli[0] if len(update.alt_cli) > 0 else update.fix_cli
350 pip_update = update.alt_cli[1] if len(update.alt_cli) > 1 else scoop_update
351 free_suggestions.append(
352 f"YouTube returned {videos} videos but only {transcripts} transcripts "
353 f"captured{captions_note}. The most common remaining cause is a stale "
354 "yt-dlp binary - YouTube's caption format changes frequently and old "
355 "binaries silently fail every transcript. Update via your package "
356 f"manager: {scoop_update} (Windows), {update.fix_cli} (macOS), "
357 f"or {pip_update}."
358 )
359
360 if "instagram" in bonus_errored:
361 free_suggestions.append(
362 "Instagram returned 0 reels despite SC being configured. SC's "
363 "v2 reels endpoint wraps Google Search and 500's frequently on "
364 "multi-token queries. The skill now retries with hashtag-form "
365 "automatically; if zero items still appear, the topic may have "
366 "no reel coverage on Instagram. Try a single-word topic like "
367 "the most distinctive noun in your query."
368 )
369
370 # Mention bonus opt-in sources when SC key is present
371 if has_sc:
372 bonus_hints = []
373 if "threads" not in (active_sources or []):
374 bonus_hints.append("Threads")
375 if "pinterest" not in (active_sources or []):
376 bonus_hints.append("Pinterest")
377 if bonus_hints:
378 free_suggestions.append(
379 f"Your SC key also powers {', '.join(bonus_hints)} and YouTube comments. "
380 "Add them to INCLUDE_SOURCES in your .env to enable."
381 )
382
383 if free_suggestions:
384 lines.append("Free fixes:")
385 for s in free_suggestions:
386 lines.append(f" - {s}")
387 lines.append("")
388
389 # Bonus sources mention (non-blocking)
390 if not has_sc:
391 lines.append(
392 "Bonus: TikTok and Instagram are available with a free "
393 "ScrapeCreators key at scrapecreators.com (no affiliation)."
394 )
395 else:
396 lines.append("last30days has no affiliation with any API provider.")
397
398 return "\n".join(lines)
399
399 lines PYTHON