luckiest-co 1.0.46 → 1.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +8 -0
- package/skills/video-studio/SKILL.md +6 -5
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio/scripts/qa.mjs +64 -2
- package/skills/video-studio/scripts/qa.test.mjs +29 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ipaddress
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import sys
|
|
9
10
|
from typing import Any
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
12
|
|
|
11
13
|
from . import env, http, schema
|
|
12
14
|
|
|
@@ -24,6 +26,93 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
24
26
|
# constant is suffix-free. If GEMINI_FLASH_LITE moves to a non-preview stable ID,
|
|
25
27
|
# double-check that OpenRouter's slug still maps to the same upstream model.
|
|
26
28
|
OPENROUTER_DEFAULT = "google/gemini-3.1-flash-lite-preview"
|
|
29
|
+
PROVIDER_BASE_URL_KEYS = frozenset({"OPENAI_BASE_URL", "XAI_BASE_URL", "OPENROUTER_BASE_URL"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_loopback(host: str) -> bool:
|
|
33
|
+
"""True for hosts that never leave the machine."""
|
|
34
|
+
if host == "localhost":
|
|
35
|
+
return True
|
|
36
|
+
try:
|
|
37
|
+
return ipaddress.ip_address(host).is_loopback
|
|
38
|
+
except ValueError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def allowed_base_url_override(value: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
parts = urlsplit(value)
|
|
45
|
+
host = parts.hostname
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
return bool(host) and (
|
|
49
|
+
parts.scheme == "https" or (parts.scheme == "http" and _is_loopback(host))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_loopback_http_endpoint(url: str) -> bool:
|
|
54
|
+
parts = urlsplit(url)
|
|
55
|
+
return parts.scheme == "http" and _is_loopback(parts.hostname or "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def base_url_override(key: str, default: str) -> str:
|
|
59
|
+
"""Resolve a provider base-URL override, refusing cleartext remote hosts.
|
|
60
|
+
|
|
61
|
+
Every provider resolves its endpoint through here so one guard covers both
|
|
62
|
+
ways a value arrives: the process environment, and
|
|
63
|
+
``_propagate_config_to_environ`` pushing a `.env` value into os.environ.
|
|
64
|
+
|
|
65
|
+
A base-URL override redirects the request that carries the provider's bearer
|
|
66
|
+
token, so an `http://` override to a remote host would put the API key on
|
|
67
|
+
the wire in cleartext. That is refused and the built-in vendor endpoint is
|
|
68
|
+
used instead - failing closed protects the credential, and the warning makes
|
|
69
|
+
the drop visible instead of leaving the user to wonder why their gateway is
|
|
70
|
+
being bypassed. Loopback is the one legitimate `http://` case (a local
|
|
71
|
+
LiteLLM/Ollama gateway or an SSH tunnel never leaves the machine), so it is
|
|
72
|
+
allowed.
|
|
73
|
+
"""
|
|
74
|
+
raw = (os.environ.get(key) or "").strip()
|
|
75
|
+
if not raw:
|
|
76
|
+
return default
|
|
77
|
+
if allowed_base_url_override(raw):
|
|
78
|
+
return raw
|
|
79
|
+
sys.stderr.write(
|
|
80
|
+
f"[last30days] WARNING: ignoring {key} - a provider endpoint override "
|
|
81
|
+
"must be https:// (http:// is allowed only on localhost), otherwise the API "
|
|
82
|
+
f"key would be sent in cleartext. Using {default} instead.\n"
|
|
83
|
+
)
|
|
84
|
+
sys.stderr.flush()
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_ENDPOINT_PATHS = {
|
|
89
|
+
OPENAI_RESPONSES_URL: "/responses",
|
|
90
|
+
XAI_RESPONSES_URL: "/responses",
|
|
91
|
+
OPENROUTER_URL: "/chat/completions",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_endpoint(env_var: str, default_url: str) -> str:
|
|
96
|
+
"""Resolve a ``*_BASE_URL`` override into a full endpoint URL.
|
|
97
|
+
|
|
98
|
+
By the convention every OpenAI-compatible provider documents, ``*_BASE_URL``
|
|
99
|
+
names the API root (``https://host/v1``) and the client appends the endpoint
|
|
100
|
+
path. This module historically required the full endpoint URL instead, so a
|
|
101
|
+
value copied from a provider's setup guide POSTed to the API root and failed.
|
|
102
|
+
|
|
103
|
+
Accept both forms: a host or versioned API root gets the endpoint path
|
|
104
|
+
appended, and a complete gateway route is used unchanged. Query strings
|
|
105
|
+
stay after the path in either form.
|
|
106
|
+
"""
|
|
107
|
+
override = base_url_override(env_var, default_url)
|
|
108
|
+
if override == default_url:
|
|
109
|
+
return default_url
|
|
110
|
+
parts = urlsplit(override)
|
|
111
|
+
root_path = parts.path.rstrip("/")
|
|
112
|
+
last_segment = root_path.rsplit("/", 1)[-1]
|
|
113
|
+
if not root_path or re.fullmatch(r"v\d+(?:beta\d*)?", last_segment):
|
|
114
|
+
return urlunsplit(parts._replace(path=root_path + _ENDPOINT_PATHS[default_url]))
|
|
115
|
+
return override
|
|
27
116
|
|
|
28
117
|
|
|
29
118
|
class ReasoningClient:
|
|
@@ -31,6 +120,95 @@ class ReasoningClient:
|
|
|
31
120
|
|
|
32
121
|
name: str
|
|
33
122
|
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self._usage_calls = 0
|
|
125
|
+
self._usage_prompt_tokens = 0
|
|
126
|
+
self._usage_completion_tokens = 0
|
|
127
|
+
self._usage_complete = True
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _valid_token_count(value: Any) -> bool:
|
|
131
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
132
|
+
|
|
133
|
+
def record_usage(
|
|
134
|
+
self,
|
|
135
|
+
prompt_tokens: Any,
|
|
136
|
+
completion_tokens: Any,
|
|
137
|
+
total_tokens: Any = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._usage_calls += 1
|
|
140
|
+
if not self._valid_token_count(prompt_tokens):
|
|
141
|
+
self._usage_complete = False
|
|
142
|
+
return
|
|
143
|
+
if total_tokens is not None:
|
|
144
|
+
if (
|
|
145
|
+
not self._valid_token_count(total_tokens)
|
|
146
|
+
or total_tokens < prompt_tokens
|
|
147
|
+
or (
|
|
148
|
+
completion_tokens is not None
|
|
149
|
+
and (
|
|
150
|
+
not self._valid_token_count(completion_tokens)
|
|
151
|
+
or total_tokens < prompt_tokens + completion_tokens
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
):
|
|
155
|
+
self._usage_complete = False
|
|
156
|
+
return
|
|
157
|
+
# Reported totals can include reasoning tokens absent from completion counts.
|
|
158
|
+
completion_tokens = total_tokens - prompt_tokens
|
|
159
|
+
elif not self._valid_token_count(completion_tokens):
|
|
160
|
+
self._usage_complete = False
|
|
161
|
+
return
|
|
162
|
+
self._usage_prompt_tokens += prompt_tokens
|
|
163
|
+
self._usage_completion_tokens += completion_tokens
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def total_usage(self) -> dict[str, int] | None:
|
|
167
|
+
if not self._usage_calls or not self._usage_complete:
|
|
168
|
+
return None
|
|
169
|
+
return {
|
|
170
|
+
"calls": self._usage_calls,
|
|
171
|
+
"promptTokens": self._usage_prompt_tokens,
|
|
172
|
+
"completionTokens": self._usage_completion_tokens,
|
|
173
|
+
"totalTokens": self._usage_prompt_tokens + self._usage_completion_tokens,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def _mark_usage_incomplete(self) -> None:
|
|
177
|
+
self._usage_complete = False
|
|
178
|
+
|
|
179
|
+
def _post(self, url: str, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
|
|
180
|
+
try:
|
|
181
|
+
return http.post(url, payload, on_retry=self._mark_usage_incomplete, **kwargs)
|
|
182
|
+
except Exception:
|
|
183
|
+
self._mark_usage_incomplete()
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
def _record_response_usage(
|
|
187
|
+
self,
|
|
188
|
+
response: dict[str, Any],
|
|
189
|
+
*,
|
|
190
|
+
metadata_key: str,
|
|
191
|
+
prompt_key: str,
|
|
192
|
+
completion_key: str,
|
|
193
|
+
total_key: str,
|
|
194
|
+
prompt_fallback_key: str | None = None,
|
|
195
|
+
completion_fallback_key: str | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
usage = response.get(metadata_key)
|
|
198
|
+
if not isinstance(usage, dict):
|
|
199
|
+
usage = {}
|
|
200
|
+
prompt_tokens = usage.get(prompt_key)
|
|
201
|
+
completion_tokens = usage.get(completion_key)
|
|
202
|
+
if prompt_tokens is None and prompt_fallback_key:
|
|
203
|
+
prompt_tokens = usage.get(prompt_fallback_key)
|
|
204
|
+
if completion_tokens is None and completion_fallback_key:
|
|
205
|
+
completion_tokens = usage.get(completion_fallback_key)
|
|
206
|
+
self.record_usage(
|
|
207
|
+
prompt_tokens,
|
|
208
|
+
completion_tokens,
|
|
209
|
+
usage.get(total_key),
|
|
210
|
+
)
|
|
211
|
+
|
|
34
212
|
def generate_text(
|
|
35
213
|
self,
|
|
36
214
|
model: str,
|
|
@@ -56,6 +234,7 @@ class GeminiClient(ReasoningClient):
|
|
|
56
234
|
name = "gemini"
|
|
57
235
|
|
|
58
236
|
def __init__(self, api_key: str):
|
|
237
|
+
super().__init__()
|
|
59
238
|
self.api_key = api_key
|
|
60
239
|
|
|
61
240
|
def _generate_content(
|
|
@@ -74,7 +253,7 @@ class GeminiClient(ReasoningClient):
|
|
|
74
253
|
body["generationConfig"]["responseMimeType"] = response_mime_type
|
|
75
254
|
if tools:
|
|
76
255
|
body["tools"] = tools
|
|
77
|
-
return
|
|
256
|
+
return self._post(
|
|
78
257
|
GEMINI_URL.format(model=model, api_key=self.api_key),
|
|
79
258
|
body,
|
|
80
259
|
headers={"Content-Type": "application/json"},
|
|
@@ -95,12 +274,20 @@ class GeminiClient(ReasoningClient):
|
|
|
95
274
|
tools=tools,
|
|
96
275
|
response_mime_type=response_mime_type,
|
|
97
276
|
)
|
|
277
|
+
self._record_response_usage(
|
|
278
|
+
payload,
|
|
279
|
+
metadata_key="usageMetadata",
|
|
280
|
+
prompt_key="promptTokenCount",
|
|
281
|
+
completion_key="candidatesTokenCount",
|
|
282
|
+
total_key="totalTokenCount",
|
|
283
|
+
)
|
|
98
284
|
return extract_gemini_text(payload)
|
|
99
285
|
|
|
100
286
|
class OpenAIClient(ReasoningClient):
|
|
101
287
|
name = "openai"
|
|
102
288
|
|
|
103
289
|
def __init__(self, token: str):
|
|
290
|
+
super().__init__()
|
|
104
291
|
self.token = token
|
|
105
292
|
|
|
106
293
|
def generate_text(
|
|
@@ -118,14 +305,23 @@ class OpenAIClient(ReasoningClient):
|
|
|
118
305
|
"input": prompt,
|
|
119
306
|
"temperature": 0,
|
|
120
307
|
}
|
|
121
|
-
|
|
122
|
-
|
|
308
|
+
endpoint = resolve_endpoint("OPENAI_BASE_URL", OPENAI_RESPONSES_URL)
|
|
309
|
+
response = self._post(
|
|
310
|
+
endpoint,
|
|
123
311
|
payload,
|
|
124
312
|
headers={
|
|
125
313
|
"Authorization": f"Bearer {self.token}",
|
|
126
314
|
"Content-Type": "application/json",
|
|
127
315
|
},
|
|
128
316
|
timeout=90,
|
|
317
|
+
bypass_proxy=is_loopback_http_endpoint(endpoint),
|
|
318
|
+
)
|
|
319
|
+
self._record_response_usage(
|
|
320
|
+
response,
|
|
321
|
+
metadata_key="usage",
|
|
322
|
+
prompt_key="input_tokens",
|
|
323
|
+
completion_key="output_tokens",
|
|
324
|
+
total_key="total_tokens",
|
|
129
325
|
)
|
|
130
326
|
return extract_openai_text(response)
|
|
131
327
|
|
|
@@ -134,6 +330,7 @@ class XAIClient(ReasoningClient):
|
|
|
134
330
|
name = "xai"
|
|
135
331
|
|
|
136
332
|
def __init__(self, api_key: str):
|
|
333
|
+
super().__init__()
|
|
137
334
|
self.api_key = api_key
|
|
138
335
|
|
|
139
336
|
def generate_text(
|
|
@@ -149,14 +346,25 @@ class XAIClient(ReasoningClient):
|
|
|
149
346
|
"model": model,
|
|
150
347
|
"input": [{"role": "user", "content": prompt}],
|
|
151
348
|
}
|
|
152
|
-
|
|
153
|
-
|
|
349
|
+
endpoint = resolve_endpoint("XAI_BASE_URL", XAI_RESPONSES_URL)
|
|
350
|
+
response = self._post(
|
|
351
|
+
endpoint,
|
|
154
352
|
payload,
|
|
155
353
|
headers={
|
|
156
354
|
"Authorization": f"Bearer {self.api_key}",
|
|
157
355
|
"Content-Type": "application/json",
|
|
158
356
|
},
|
|
159
357
|
timeout=90,
|
|
358
|
+
bypass_proxy=is_loopback_http_endpoint(endpoint),
|
|
359
|
+
)
|
|
360
|
+
self._record_response_usage(
|
|
361
|
+
response,
|
|
362
|
+
metadata_key="usage",
|
|
363
|
+
prompt_key="input_tokens",
|
|
364
|
+
completion_key="output_tokens",
|
|
365
|
+
total_key="total_tokens",
|
|
366
|
+
prompt_fallback_key="prompt_tokens",
|
|
367
|
+
completion_fallback_key="completion_tokens",
|
|
160
368
|
)
|
|
161
369
|
return extract_openai_text(response)
|
|
162
370
|
|
|
@@ -165,6 +373,7 @@ class OpenRouterClient(ReasoningClient):
|
|
|
165
373
|
name = "openrouter"
|
|
166
374
|
|
|
167
375
|
def __init__(self, api_key: str):
|
|
376
|
+
super().__init__()
|
|
168
377
|
self.api_key = api_key
|
|
169
378
|
|
|
170
379
|
def generate_text(
|
|
@@ -181,14 +390,23 @@ class OpenRouterClient(ReasoningClient):
|
|
|
181
390
|
"messages": [{"role": "user", "content": prompt}],
|
|
182
391
|
"temperature": 0,
|
|
183
392
|
}
|
|
184
|
-
|
|
185
|
-
|
|
393
|
+
endpoint = resolve_endpoint("OPENROUTER_BASE_URL", OPENROUTER_URL)
|
|
394
|
+
response = self._post(
|
|
395
|
+
endpoint,
|
|
186
396
|
payload,
|
|
187
397
|
headers={
|
|
188
398
|
"Authorization": f"Bearer {self.api_key}",
|
|
189
399
|
"Content-Type": "application/json",
|
|
190
400
|
},
|
|
191
401
|
timeout=90,
|
|
402
|
+
bypass_proxy=is_loopback_http_endpoint(endpoint),
|
|
403
|
+
)
|
|
404
|
+
self._record_response_usage(
|
|
405
|
+
response,
|
|
406
|
+
metadata_key="usage",
|
|
407
|
+
prompt_key="prompt_tokens",
|
|
408
|
+
completion_key="completion_tokens",
|
|
409
|
+
total_key="total_tokens",
|
|
192
410
|
)
|
|
193
411
|
return extract_openai_text(response)
|
|
194
412
|
|
|
@@ -315,9 +533,12 @@ def resolve_runtime(config: dict[str, Any], depth: str) -> tuple[schema.Provider
|
|
|
315
533
|
|
|
316
534
|
|
|
317
535
|
def _resolve_x_backend(config: dict[str, Any]) -> str | None:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
536
|
+
"""Resolve the X backend for runtime fetch.
|
|
537
|
+
|
|
538
|
+
Delegates to env.get_x_source which handles:
|
|
539
|
+
- Any known pin (X_BACKEND_KNOWN) exclusively: returns pin if available, None otherwise
|
|
540
|
+
- Unpinned: walks auto-chain (X_BACKEND_ORDER) only, never auto-selects opt-in backends
|
|
541
|
+
"""
|
|
321
542
|
return env.get_x_source(config)
|
|
322
543
|
|
|
323
544
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
"""Post-research quality score and upgrade nudge.
|
|
2
2
|
|
|
3
|
-
Computes a quality score based on
|
|
3
|
+
Computes a quality score based on the non-blocking core sources and builds
|
|
4
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.
|
|
5
9
|
"""
|
|
6
10
|
|
|
7
|
-
from typing import List
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from . import env, health, http, prescriptions, x_envelope
|
|
8
14
|
|
|
9
15
|
|
|
10
|
-
#
|
|
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.
|
|
11
19
|
CORE_SOURCES = ["hn", "polymarket", "x", "youtube", "reddit"]
|
|
12
20
|
|
|
13
21
|
# Labels for display
|
|
@@ -22,6 +30,8 @@ SOURCE_LABELS = {
|
|
|
22
30
|
|
|
23
31
|
def _is_x_active(config: dict, research_results: dict) -> bool:
|
|
24
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"))
|
|
25
35
|
has_creds = _has_x_credentials(config)
|
|
26
36
|
if not has_creds:
|
|
27
37
|
return False
|
|
@@ -32,12 +42,60 @@ def _is_x_active(config: dict, research_results: dict) -> bool:
|
|
|
32
42
|
|
|
33
43
|
|
|
34
44
|
def _has_x_credentials(config: dict) -> bool:
|
|
35
|
-
"""Return True when any X/Twitter source credential is configured.
|
|
36
|
-
|
|
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 (
|
|
37
53
|
config.get("AUTH_TOKEN")
|
|
38
54
|
or config.get("XAI_API_KEY")
|
|
39
55
|
or config.get("XQUIK_API_KEY")
|
|
40
|
-
)
|
|
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
|
+
The pipeline stamps the failed backend into ``x_error`` or
|
|
67
|
+
``x_degraded_error``, sometimes behind a simplified-query retry label.
|
|
68
|
+
Use that runtime provenance before the host policy.
|
|
69
|
+
"""
|
|
70
|
+
message = str(research_results.get("x_error") or research_results.get("x_degraded_error") or "")
|
|
71
|
+
if message.startswith(x_envelope.DETAIL_NOT_PASSED):
|
|
72
|
+
# The model declared the X connector lane and passed no envelope:
|
|
73
|
+
# the fix is the connector, on any host.
|
|
74
|
+
return prescriptions.for_x(config, "connector_missing")
|
|
75
|
+
backend_error = message.removeprefix("Simplified-query retry failed: ")
|
|
76
|
+
backend_error = backend_error.removeprefix("All X backends failed — ")
|
|
77
|
+
if backend_error.startswith("X served via "):
|
|
78
|
+
served_backend, separator, origin_error = backend_error.removeprefix("X served via ").partition(" after ")
|
|
79
|
+
if separator:
|
|
80
|
+
backend_error = origin_error.split(f"; {served_backend}", 1)[0]
|
|
81
|
+
if backend_error.startswith("xai:"):
|
|
82
|
+
state = http.classify_failure(message=backend_error)
|
|
83
|
+
if state == health.PAYMENT_REQUIRED:
|
|
84
|
+
failure = "xai_payment_required"
|
|
85
|
+
elif state == health.RATE_LIMITED:
|
|
86
|
+
failure = "xai_rate_limited"
|
|
87
|
+
elif state == health.TIMEOUT:
|
|
88
|
+
failure = "xai_timeout"
|
|
89
|
+
elif state == health.AUTH_FAILED or "model" in backend_error.lower():
|
|
90
|
+
failure = "xai_error"
|
|
91
|
+
else:
|
|
92
|
+
failure = "xai_unavailable"
|
|
93
|
+
return prescriptions.for_x(config, failure)
|
|
94
|
+
failure = "cookies_expired"
|
|
95
|
+
if env.x_policy(config).hint_namespace == "official":
|
|
96
|
+
if http.classify_failure(message=message) == health.PAYMENT_REQUIRED:
|
|
97
|
+
failure = "payment_required"
|
|
98
|
+
return prescriptions.for_x(config, failure)
|
|
41
99
|
|
|
42
100
|
|
|
43
101
|
def _has_ytdlp() -> bool:
|
|
@@ -143,7 +201,7 @@ def _is_instagram_silent_failure(config: dict, research_results: dict) -> bool:
|
|
|
143
201
|
|
|
144
202
|
|
|
145
203
|
def compute_quality_score(config: dict, research_results: dict) -> dict:
|
|
146
|
-
"""Compute research quality score
|
|
204
|
+
"""Compute research quality score from the non-blocking core sources.
|
|
147
205
|
|
|
148
206
|
Args:
|
|
149
207
|
config: Configuration dict from env.get_config()
|
|
@@ -153,13 +211,14 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
|
|
|
153
211
|
degraded-YouTube detection (transcript-fetch ratio below threshold,
|
|
154
212
|
or fallback/provider data returned without local yt-dlp).
|
|
155
213
|
Optional key ``instagram_items_count`` enables silent-failure
|
|
156
|
-
detection for the bonus Instagram source.
|
|
214
|
+
detection for the bonus Instagram source. ``x_degraded_error``
|
|
215
|
+
carries a failed xAI lane when another backend served X items.
|
|
157
216
|
|
|
158
217
|
Returns:
|
|
159
218
|
{
|
|
160
|
-
"score_pct":
|
|
219
|
+
"score_pct": 0-100,
|
|
161
220
|
"core_active": ["hn", "polymarket", ...],
|
|
162
|
-
"core_missing": ["
|
|
221
|
+
"core_missing": ["youtube"],
|
|
163
222
|
"core_errored": [], # configured but errored at top level
|
|
164
223
|
"core_degraded": [], # configured and returned items but quality below threshold
|
|
165
224
|
"bonus_errored": [], # bonus sources (Instagram, etc.) configured but silent
|
|
@@ -177,14 +236,22 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
|
|
|
177
236
|
core_active.append("polymarket")
|
|
178
237
|
core_active.append("reddit")
|
|
179
238
|
|
|
180
|
-
# X
|
|
181
|
-
|
|
239
|
+
# X splits three ways. Active counts normally. Configured-but-errored is a
|
|
240
|
+
# real outage: it still docks the score and surfaces a repair, never an
|
|
241
|
+
# "optional omission". Only unconfigured/declined X leaves the denominator.
|
|
242
|
+
optional_omitted: List[str] = []
|
|
243
|
+
x_configured = _has_x_credentials(config) or (
|
|
244
|
+
"x" in (research_results.get("active_sources") or [])
|
|
245
|
+
)
|
|
182
246
|
if _is_x_active(config, research_results):
|
|
183
247
|
core_active.append("x")
|
|
184
|
-
|
|
248
|
+
if research_results.get("x_degraded_error"):
|
|
249
|
+
core_degraded.append("x")
|
|
250
|
+
elif x_configured and research_results.get("x_error"):
|
|
185
251
|
core_missing.append("x")
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
core_errored.append("x")
|
|
253
|
+
else:
|
|
254
|
+
optional_omitted.append("x")
|
|
188
255
|
|
|
189
256
|
# YouTube
|
|
190
257
|
has_ytdlp = _has_ytdlp()
|
|
@@ -219,10 +286,12 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
|
|
|
219
286
|
if _is_instagram_silent_failure(config, research_results):
|
|
220
287
|
bonus_errored.append("instagram")
|
|
221
288
|
|
|
222
|
-
|
|
289
|
+
scored_source_count = len(CORE_SOURCES) - len(optional_omitted)
|
|
290
|
+
score_pct = int(len(core_active) / scored_source_count * 100)
|
|
223
291
|
|
|
224
292
|
has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
|
|
225
293
|
active_sources = research_results.get("active_sources") or []
|
|
294
|
+
x_fix = _x_error_prescription(config, research_results) if "x" in core_errored or "x" in core_degraded else None
|
|
226
295
|
nudge_text = _build_nudge_text(
|
|
227
296
|
core_missing,
|
|
228
297
|
core_errored,
|
|
@@ -232,6 +301,8 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
|
|
|
232
301
|
active_sources=active_sources,
|
|
233
302
|
bonus_errored=bonus_errored,
|
|
234
303
|
has_ytdlp=has_ytdlp,
|
|
304
|
+
core_total=scored_source_count,
|
|
305
|
+
x_fix=x_fix,
|
|
235
306
|
) if (core_missing or core_degraded or bonus_errored) else None
|
|
236
307
|
|
|
237
308
|
return {
|
|
@@ -254,6 +325,8 @@ def _build_nudge_text(
|
|
|
254
325
|
active_sources: list = None,
|
|
255
326
|
bonus_errored: List[str] = None,
|
|
256
327
|
has_ytdlp: bool = False,
|
|
328
|
+
core_total: int | None = None,
|
|
329
|
+
x_fix: Optional[prescriptions.Prescription] = None,
|
|
257
330
|
) -> str:
|
|
258
331
|
"""Build human-readable nudge text describing what was missed or degraded.
|
|
259
332
|
|
|
@@ -274,8 +347,9 @@ def _build_nudge_text(
|
|
|
274
347
|
else:
|
|
275
348
|
missed_parts.append(label)
|
|
276
349
|
|
|
277
|
-
|
|
278
|
-
|
|
350
|
+
effective_total = core_total if core_total is not None else len(CORE_SOURCES)
|
|
351
|
+
active_count = effective_total - len(core_missing)
|
|
352
|
+
lines.append(f"Research quality: {active_count}/{effective_total} core sources.")
|
|
279
353
|
if missed_parts:
|
|
280
354
|
lines.append(f"Missing: {', '.join(missed_parts)}.")
|
|
281
355
|
if core_degraded:
|
|
@@ -289,29 +363,30 @@ def _build_nudge_text(
|
|
|
289
363
|
# Free suggestions
|
|
290
364
|
free_suggestions: List[str] = []
|
|
291
365
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
)
|
|
366
|
+
# A configured X that errored is the only X entry that can reach
|
|
367
|
+
# core_missing: unconfigured/declined X is an optional omission and never
|
|
368
|
+
# lands here. Surface the repair instead of hiding the outage.
|
|
369
|
+
if "x" in core_missing and "x" in core_errored:
|
|
370
|
+
if x_fix is None:
|
|
371
|
+
x_fix = prescriptions.get("x", "cookies_expired")
|
|
372
|
+
free_suggestions.append(f"X/Twitter errored - {x_fix.fix_nl}.")
|
|
373
|
+
|
|
374
|
+
if "x" in core_degraded:
|
|
375
|
+
if x_fix is None:
|
|
376
|
+
x_fix = prescriptions.get("x", "cookies_expired")
|
|
377
|
+
free_suggestions.append(f"X/Twitter used a backup - {x_fix.fix_nl}.")
|
|
305
378
|
|
|
306
379
|
if "youtube" in core_missing:
|
|
307
380
|
if "youtube" in core_errored:
|
|
381
|
+
yt_fix = prescriptions.get("youtube", "ytdlp_stale")
|
|
308
382
|
free_suggestions.append(
|
|
309
|
-
"YouTube errored - update yt-dlp:
|
|
383
|
+
f"YouTube errored - update yt-dlp: {yt_fix.fix_cli}"
|
|
310
384
|
)
|
|
311
385
|
else:
|
|
386
|
+
yt_fix = prescriptions.get("youtube", "ytdlp_missing")
|
|
312
387
|
free_suggestions.append(
|
|
313
388
|
"YouTube: video transcripts with key moments - often the deepest "
|
|
314
|
-
"explanations on any topic. Install yt-dlp:
|
|
389
|
+
f"explanations on any topic. Install yt-dlp: {yt_fix.fix_cli} (free)"
|
|
315
390
|
)
|
|
316
391
|
|
|
317
392
|
if "youtube" in core_degraded:
|
|
@@ -319,12 +394,18 @@ def _build_nudge_text(
|
|
|
319
394
|
transcripts = int(research_results.get("youtube_transcripts_count") or 0)
|
|
320
395
|
captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
|
|
321
396
|
if not has_ytdlp and _youtube_returned_data(research_results):
|
|
397
|
+
install = prescriptions.get("youtube", "ytdlp_missing")
|
|
398
|
+
# Tolerant lookup: alt_cli makes no arity promise, so an entry
|
|
399
|
+
# gaining/losing a platform alternate must degrade the wording,
|
|
400
|
+
# never crash the nudge path.
|
|
401
|
+
scoop_install = install.alt_cli[0] if len(install.alt_cli) > 0 else install.fix_cli
|
|
402
|
+
pip_install = install.alt_cli[1] if len(install.alt_cli) > 1 else scoop_install
|
|
322
403
|
free_suggestions.append(
|
|
323
404
|
f"YouTube returned {videos} videos and {transcripts} transcripts "
|
|
324
405
|
"through a fallback/provider path, but local yt-dlp is not "
|
|
325
406
|
"installed. Install yt-dlp to enable the free local YouTube lane "
|
|
326
|
-
"and reduce reliance on fallback providers:
|
|
327
|
-
"(macOS),
|
|
407
|
+
f"and reduce reliance on fallback providers: {install.fix_cli} "
|
|
408
|
+
f"(macOS), {scoop_install} (Windows), or {pip_install}."
|
|
328
409
|
)
|
|
329
410
|
else:
|
|
330
411
|
captions_note = ""
|
|
@@ -333,13 +414,17 @@ def _build_nudge_text(
|
|
|
333
414
|
f" ({captions_disabled} of those had captions disabled by the "
|
|
334
415
|
"uploader, which is a separate cause and not fixable on your end)"
|
|
335
416
|
)
|
|
417
|
+
update = prescriptions.get("youtube", "ytdlp_stale")
|
|
418
|
+
# Same tolerant lookup as the install branch above.
|
|
419
|
+
scoop_update = update.alt_cli[0] if len(update.alt_cli) > 0 else update.fix_cli
|
|
420
|
+
pip_update = update.alt_cli[1] if len(update.alt_cli) > 1 else scoop_update
|
|
336
421
|
free_suggestions.append(
|
|
337
422
|
f"YouTube returned {videos} videos but only {transcripts} transcripts "
|
|
338
423
|
f"captured{captions_note}. The most common remaining cause is a stale "
|
|
339
424
|
"yt-dlp binary - YouTube's caption format changes frequently and old "
|
|
340
425
|
"binaries silently fail every transcript. Update via your package "
|
|
341
|
-
"manager:
|
|
342
|
-
"or
|
|
426
|
+
f"manager: {scoop_update} (Windows), {update.fix_cli} (macOS), "
|
|
427
|
+
f"or {pip_update}."
|
|
343
428
|
)
|
|
344
429
|
|
|
345
430
|
if "instagram" in bonus_errored:
|