| 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 |