| 1 | """Fix-prescription registry: the single remediation vocabulary (KTD 7). |
| 2 | |
| 3 | Each (source, failure mode) entry carries a cause line, a natural-language |
| 4 | fix, an exact CLI fix, and an optional CONFIGURATION.md anchor. Two real |
| 5 | consumers keep the vocabulary honest from day one: |
| 6 | |
| 7 | - ``lib/quality_nudge.py`` builds its post-research fix text from these |
| 8 | entries (only the fix strings migrated here; trigger logic is untouched). |
| 9 | - The doctor aggregator (U4) looks entries up per failed source/backend. |
| 10 | |
| 11 | Because both surfaces read the same entry, the nudge a user sees after a |
| 12 | degraded run and the prescription doctor prints for the same failure can |
| 13 | never drift apart. |
| 14 | |
| 15 | Composition with the other health layers (reference, don't restate): |
| 16 | |
| 17 | - U1 (``lib/health.py``) owns the machine-aware package-manager strings |
| 18 | (brew/pipx/apt/npx install-vs-reinstall, off-PATH PATH edits). Binary-class |
| 19 | entries here pull their static defaults from U1's tables, and |
| 20 | ``for_dependency_probe`` lets a live probe's machine-specific prescription |
| 21 | win the CLI form while the registry supplies cause/NL/anchor vocabulary. |
| 22 | - U2 (``lib/backends.py``) embeds this registry's CLI forms inside its |
| 23 | chain-failure prescriptions, so a backend finding and a registry lookup |
| 24 | agree on the command to run. |
| 25 | |
| 26 | No secrets: CLI forms use obvious ``<placeholder>`` values only. |
| 27 | """ |
| 28 | |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | from dataclasses import dataclass, replace |
| 32 | from typing import Dict, Optional, Tuple |
| 33 | |
| 34 | from . import health |
| 35 | |
| 36 | # Direct engine invocation prefix (scripting fallback; the slash-command UX |
| 37 | # is "ask the agent to run setup ...", which is the natural-language form). |
| 38 | ENGINE_CLI = "python3 skills/last30days/scripts/last30days.py" |
| 39 | SETUP_BROWSER_COOKIES_CLI = f"{ENGINE_CLI} setup --allow-browser-cookies" |
| 40 | SETUP_GITHUB_CLI = f"{ENGINE_CLI} setup --github" |
| 41 | |
| 42 | # U1 owns these remediation strings; reference them instead of restating. |
| 43 | _YTDLP_BREW_INSTALL, _YTDLP_BREW_REINSTALL = health.static_prescription("yt-dlp", "brew") |
| 44 | _YTDLP_PIPX_REINSTALL = health.static_prescription("yt-dlp", "pipx")[1] |
| 45 | _DIGG_PP_INSTALL_CLI = health.pp_install_cmd("digg") |
| 46 | |
| 47 | GENERIC_FIX_NL = "see CONFIGURATION.md for setup options for this source" |
| 48 | |
| 49 | |
| 50 | @dataclass(frozen=True) |
| 51 | class Prescription: |
| 52 | """Remediation for one (source, failure mode). |
| 53 | |
| 54 | ``fix_nl`` is the natural-language form ("ask the agent to run setup |
| 55 | with browser-cookie consent"); ``fix_cli`` is the exact command. |
| 56 | ``alt_cli`` carries per-platform alternates (Windows/pip) when the |
| 57 | primary CLI form is macOS/brew. ``anchor`` is a CONFIGURATION.md |
| 58 | heading anchor ("" when the doc has no dedicated section). |
| 59 | """ |
| 60 | |
| 61 | source: str |
| 62 | failure: str |
| 63 | cause: str |
| 64 | fix_nl: str |
| 65 | fix_cli: str |
| 66 | alt_cli: Tuple[str, ...] = () |
| 67 | anchor: str = "" |
| 68 | |
| 69 | |
| 70 | def _entry(source: str, failure: str, **kwargs) -> Tuple[Tuple[str, str], Prescription]: |
| 71 | return (source, failure), Prescription(source=source, failure=failure, **kwargs) |
| 72 | |
| 73 | |
| 74 | REGISTRY: Dict[Tuple[str, str], Prescription] = dict(( |
| 75 | _entry( |
| 76 | "x", "cookies_missing", |
| 77 | cause="X browser cookies (AUTH_TOKEN/CT0) are not configured", |
| 78 | fix_nl=( |
| 79 | "log into x.com in your browser and re-run (cookies detected " |
| 80 | "automatically), or add XAI_API_KEY to your .env (get key at " |
| 81 | "api.x.ai), or add XQUIK_API_KEY to your .env (get key at xquik.com)" |
| 82 | ), |
| 83 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 84 | anchor="api-keys-env", |
| 85 | ), |
| 86 | _entry( |
| 87 | "x", "cookies_expired", |
| 88 | cause="X errored this run: cookies are configured but likely expired or revoked", |
| 89 | fix_nl="log into x.com in your browser, then re-run", |
| 90 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 91 | anchor="api-keys-env", |
| 92 | ), |
| 93 | _entry( |
| 94 | "scrapecreators", "key_missing", |
| 95 | cause="SCRAPECREATORS_API_KEY is not set", |
| 96 | fix_nl=( |
| 97 | "ask the agent to run setup with the GitHub device flow " |
| 98 | "(free 10,000-call signup; the key is persisted automatically)" |
| 99 | ), |
| 100 | fix_cli=SETUP_GITHUB_CLI, |
| 101 | anchor="api-keys-env", |
| 102 | ), |
| 103 | _entry( |
| 104 | "bluesky", "app_password_missing", |
| 105 | cause="BSKY_HANDLE and/or BSKY_APP_PASSWORD are not set", |
| 106 | fix_nl=( |
| 107 | "generate an app password at bsky.app/settings/app-passwords and " |
| 108 | "add BSKY_HANDLE plus BSKY_APP_PASSWORD to ~/.config/last30days/.env" |
| 109 | ), |
| 110 | fix_cli="BSKY_HANDLE=<your-handle> BSKY_APP_PASSWORD=<xxxx-xxxx-xxxx-xxxx>", |
| 111 | anchor="bluesky-app-password-format-and-search-host", |
| 112 | ), |
| 113 | _entry( |
| 114 | "youtube", "transcription_key_missing", |
| 115 | cause=( |
| 116 | "no transcription provider key for the caption-free transcript " |
| 117 | "backstop (GROQ_API_KEY or OPENAI_API_KEY)" |
| 118 | ), |
| 119 | fix_nl=( |
| 120 | "add a free Groq key from console.groq.com to " |
| 121 | "~/.config/last30days/.env so caption-free videos still get " |
| 122 | "transcripts (OPENAI_API_KEY also works as the paid backstop)" |
| 123 | ), |
| 124 | fix_cli="GROQ_API_KEY=<your-groq-key>", |
| 125 | anchor="api-keys-env", |
| 126 | ), |
| 127 | _entry( |
| 128 | "digg", "pp_cli_missing", |
| 129 | cause="digg-pp-cli is not installed", |
| 130 | fix_nl=( |
| 131 | "install the Digg CLI through the Printing Press library, then " |
| 132 | "re-run setup so the source activates" |
| 133 | ), |
| 134 | fix_cli=_DIGG_PP_INSTALL_CLI, |
| 135 | anchor="first-run-onboarding", |
| 136 | ), |
| 137 | _entry( |
| 138 | "digg", "pp_cli_broken", |
| 139 | cause=( |
| 140 | "digg-pp-cli resolves on PATH but won't execute (broken or " |
| 141 | "hanging binary left behind by a bad install)" |
| 142 | ), |
| 143 | fix_nl=( |
| 144 | "reinstall the Digg CLI (re-run the Printing Press install) so " |
| 145 | "the binary actually executes; it is installed but not serving" |
| 146 | ), |
| 147 | fix_cli=_DIGG_PP_INSTALL_CLI, |
| 148 | anchor="first-run-onboarding", |
| 149 | ), |
| 150 | _entry( |
| 151 | "digg", "pp_cli_off_path", |
| 152 | cause=( |
| 153 | "digg-pp-cli is installed but its directory is not on the " |
| 154 | "agent-subprocess PATH" |
| 155 | ), |
| 156 | fix_nl=( |
| 157 | "add the install directory (default ~/.local/bin) to the PATH the " |
| 158 | "agent subprocess uses; the engine gate only activates the source " |
| 159 | "when the binary resolves on PATH" |
| 160 | ), |
| 161 | fix_cli='export PATH="$HOME/.local/bin:$PATH"', |
| 162 | anchor="first-run-onboarding", |
| 163 | ), |
| 164 | _entry( |
| 165 | "youtube", "ytdlp_missing", |
| 166 | cause="yt-dlp is not installed on the agent-subprocess PATH", |
| 167 | fix_nl="install yt-dlp to enable the free local YouTube lane", |
| 168 | fix_cli=_YTDLP_BREW_INSTALL, |
| 169 | alt_cli=("scoop install yt-dlp", "pip install -U yt-dlp"), |
| 170 | ), |
| 171 | _entry( |
| 172 | "youtube", "ytdlp_stale", |
| 173 | cause=( |
| 174 | "yt-dlp is installed but stale: YouTube's caption format changes " |
| 175 | "frequently and old binaries silently fail every transcript" |
| 176 | ), |
| 177 | fix_nl="update yt-dlp via your package manager", |
| 178 | fix_cli="brew upgrade yt-dlp", |
| 179 | alt_cli=("scoop update yt-dlp", "pip install -U yt-dlp"), |
| 180 | ), |
| 181 | _entry( |
| 182 | "youtube", "ytdlp_broken", |
| 183 | cause=( |
| 184 | "yt-dlp resolves on PATH but won't execute (the stale-shim class: " |
| 185 | "a wrapper left behind by an interpreter upgrade)" |
| 186 | ), |
| 187 | fix_nl=( |
| 188 | "reinstall yt-dlp so the binary actually executes; a plain " |
| 189 | "install reads as a no-op because the broken shim is still present" |
| 190 | ), |
| 191 | fix_cli=_YTDLP_BREW_REINSTALL, |
| 192 | alt_cli=(_YTDLP_PIPX_REINSTALL,), |
| 193 | ), |
| 194 | _entry( |
| 195 | "truthsocial", "token_missing", |
| 196 | cause="TRUTHSOCIAL_TOKEN is not set", |
| 197 | fix_nl=( |
| 198 | "log into truthsocial.com in your browser and let setup read the " |
| 199 | "session cookie, or copy the bearer token from your browser's dev " |
| 200 | "tools into ~/.config/last30days/.env" |
| 201 | ), |
| 202 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 203 | anchor="api-keys-env", |
| 204 | ), |
| 205 | _entry( |
| 206 | "xiaohongshu", "service_unreachable", |
| 207 | cause=( |
| 208 | "Xiaohongshu browser-session service is unreachable or not logged " |
| 209 | "in; last30days auto-probes http://localhost:18060 and " |
| 210 | "http://host.docker.internal:18060 unless XIAOHONGSHU_API_BASE is set" |
| 211 | ), |
| 212 | fix_nl=( |
| 213 | "start a local x-mcp browser plugin or xpzouying/xiaohongshu-mcp " |
| 214 | "service that can see your logged-in Xiaohongshu browser session; " |
| 215 | "set XIAOHONGSHU_API_BASE only when it runs on a custom host/port" |
| 216 | ), |
| 217 | fix_cli="XIAOHONGSHU_API_BASE=http://your-host:18060 # only for a custom host; leave unset to auto-probe localhost and host.docker.internal", |
| 218 | anchor="api-keys-env", |
| 219 | ), |
| 220 | )) |
| 221 | |
| 222 | |
| 223 | def lookup(source: str, failure: str) -> Optional[Prescription]: |
| 224 | """Return the registered entry for (source, failure), or None.""" |
| 225 | return REGISTRY.get((source, failure)) |
| 226 | |
| 227 | |
| 228 | def get(source: str, failure: str) -> Prescription: |
| 229 | """Return the registered entry, or the generic CONFIGURATION.md fallback. |
| 230 | |
| 231 | Never raises: an unregistered failure mode still yields an actionable |
| 232 | (if generic) prescription, so a report renderer cannot crash on a |
| 233 | failure class the registry has not learned yet. |
| 234 | """ |
| 235 | entry = lookup(source, failure) |
| 236 | if entry is not None: |
| 237 | return entry |
| 238 | return Prescription( |
| 239 | source=source, |
| 240 | failure=failure, |
| 241 | cause=f"{source}: {failure.replace('_', ' ')}", |
| 242 | fix_nl=GENERIC_FIX_NL, |
| 243 | fix_cli=f"{ENGINE_CLI} setup", |
| 244 | ) |
| 245 | |
| 246 | |
| 247 | # --------------------------------------------------------------------------- |
| 248 | # Composition with U1 dependency probes |
| 249 | # --------------------------------------------------------------------------- |
| 250 | |
| 251 | def _dependency_failure(probe: health.DependencyProbe) -> Optional[Tuple[str, str]]: |
| 252 | """Map a failed dependency probe onto a registered (source, failure).""" |
| 253 | if probe.name == "yt-dlp": |
| 254 | if probe.status == health.MISSING: |
| 255 | return ("youtube", "ytdlp_missing") |
| 256 | return ("youtube", "ytdlp_broken") # BROKEN and TIMEOUT: reinstall class |
| 257 | if probe.name == "digg-pp-cli": |
| 258 | # health reports off-PATH binaries as MISSING with ``off_path=True``; |
| 259 | # the distinction only picks cause/NL wording — the probe's own |
| 260 | # prescription wins the CLI form either way. |
| 261 | if probe.status == health.MISSING: |
| 262 | if probe.off_path: |
| 263 | return ("digg", "pp_cli_off_path") |
| 264 | return ("digg", "pp_cli_missing") |
| 265 | return ("digg", "pp_cli_broken") # BROKEN and TIMEOUT: reinstall class |
| 266 | return None |
| 267 | |
| 268 | |
| 269 | def for_dependency_probe(probe: health.DependencyProbe) -> Optional[Prescription]: |
| 270 | """Prescription for a failed U1 dependency probe (None when OK). |
| 271 | |
| 272 | U1's machine-aware prescription (the manager that owns the binary on |
| 273 | THIS machine, or a PATH edit for off-PATH installs) wins the CLI form; |
| 274 | the registry entry supplies the shared cause/NL/anchor vocabulary. |
| 275 | Unregistered dependencies wrap the probe so callers still get both |
| 276 | fix forms without this module restating U1's strings. |
| 277 | """ |
| 278 | if probe.ok: |
| 279 | return None |
| 280 | key = _dependency_failure(probe) |
| 281 | entry = REGISTRY.get(key) if key else None |
| 282 | if entry is None: |
| 283 | return Prescription( |
| 284 | source=probe.name, |
| 285 | failure=probe.status, |
| 286 | cause=probe.detail or f"{probe.name}: {probe.status}", |
| 287 | fix_nl=f"repair the {probe.name} install; {GENERIC_FIX_NL}", |
| 288 | fix_cli=probe.prescription or f"{ENGINE_CLI} setup", |
| 289 | ) |
| 290 | updates = {} |
| 291 | if probe.detail: |
| 292 | updates["cause"] = probe.detail |
| 293 | if probe.prescription and probe.prescription != entry.fix_cli: |
| 294 | updates["fix_cli"] = probe.prescription |
| 295 | return replace(entry, **updates) if updates else entry |
| 296 |