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