返回 last30days-skill
prescriptions.py
根目录 / skills / last30days / scripts / lib / prescriptions.py
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 env, health
35 from .x_api import BEARER_COVERAGE_NOTE
36
37 # Direct engine invocation prefix (scripting fallback; the slash-command UX
38 # is "ask the agent to run setup ...", which is the natural-language form).
39 ENGINE_CLI = "python3 skills/last30days/scripts/last30days.py"
40 SETUP_BROWSER_COOKIES_CLI = f"{ENGINE_CLI} setup --allow-browser-cookies"
41 SETUP_GITHUB_CLI = f"{ENGINE_CLI} setup --github"
42
43 # U1 owns these remediation strings; reference them instead of restating.
44 _YTDLP_BREW_INSTALL, _YTDLP_BREW_REINSTALL = health.static_prescription("yt-dlp", "brew")
45 _YTDLP_PIPX_REINSTALL = health.static_prescription("yt-dlp", "pipx")[1]
46 _DIGG_PP_INSTALL_CLI = health.pp_install_cmd("digg")
47
48 GENERIC_FIX_NL = "see CONFIGURATION.md for setup options for this source"
49
50
51 @dataclass(frozen=True)
52 class Prescription:
53 """Remediation for one (source, failure mode).
54
55 ``fix_nl`` is the natural-language form ("ask the agent to run setup
56 with browser-cookie consent"); ``fix_cli`` is the exact command.
57 ``alt_cli`` carries per-platform alternates (Windows/pip) when the
58 primary CLI form is macOS/brew. ``anchor`` is a CONFIGURATION.md
59 heading anchor ("" when the doc has no dedicated section).
60 """
61
62 source: str
63 failure: str
64 cause: str
65 fix_nl: str
66 fix_cli: str
67 alt_cli: Tuple[str, ...] = ()
68 anchor: str = ""
69
70
71 def _entry(source: str, failure: str, **kwargs) -> Tuple[Tuple[str, str], Prescription]:
72 return (source, failure), Prescription(source=source, failure=failure, **kwargs)
73
74
75 REGISTRY: Dict[Tuple[str, str], Prescription] = dict((
76 _entry(
77 "x", "cookies_missing",
78 cause="X browser cookies (AUTH_TOKEN/CT0) are not configured",
79 fix_nl=(
80 "log into x.com in your browser and re-run (cookies detected "
81 "automatically), or add XAI_API_KEY to your .env (get key at "
82 "api.x.ai), or add XQUIK_API_KEY to your .env (get key at xquik.com)"
83 ),
84 fix_cli=SETUP_BROWSER_COOKIES_CLI,
85 anchor="api-keys-env",
86 ),
87 _entry(
88 "x", "cookies_expired",
89 cause="X errored this run: cookies are configured but likely expired or revoked",
90 fix_nl="log into x.com in your browser, then re-run",
91 fix_cli=SETUP_BROWSER_COOKIES_CLI,
92 anchor="api-keys-env",
93 ),
94 _entry(
95 "x", "grok_cli_missing",
96 cause="the Grok CLI is not installed, so the keyless X path is unavailable",
97 fix_nl=(
98 "install the Grok CLI (curl -fsSL https://x.ai/cli/install.sh | bash) "
99 "and sign in with `grok login` to search X without any X credential"
100 ),
101 fix_cli="npm install -g @xai-official/grok",
102 anchor="api-keys-env",
103 ),
104 _entry(
105 "x", "grok_not_authenticated",
106 cause="the Grok CLI is installed but not signed in",
107 fix_nl="sign in to Grok once; no X account or API key is needed after that",
108 fix_cli="grok login",
109 anchor="api-keys-env",
110 ),
111 # Official X path (an official-only host per env.x_policy, or an explicit
112 # xapi pin). Copy is limited to the connector lane, X_BEARER_TOKEN,
113 # XAI_API_KEY, and X API credits; the bearer path is described as
114 # about a week, never as parity with the connector. Anchors point at
115 # the CONFIGURATION.md Grok Bot subsection (slug grok-bot).
116 _entry(
117 "x", "bearer_missing",
118 cause=(
119 "no official X path is configured (X connector, X_BEARER_TOKEN, "
120 "or XAI_API_KEY)"
121 ),
122 fix_nl=(
123 "add the X for Grok Bot plugin and connect X in Grok Bot settings (full 30-day coverage), or set "
124 f"X_BEARER_TOKEN from the X developer console ({BEARER_COVERAGE_NOTE}), "
125 "or set XAI_API_KEY from console.x.ai"
126 ),
127 fix_cli="X_BEARER_TOKEN=<your-x-api-bearer-token>",
128 anchor="grok-bot",
129 ),
130 _entry(
131 "x", "bearer_invalid",
132 cause="the X API rejected X_BEARER_TOKEN (401/403)",
133 fix_nl=(
134 "set a valid X_BEARER_TOKEN from the X developer console "
135 f"({BEARER_COVERAGE_NOTE}), or add the X for Grok Bot plugin and connect X in Grok Bot settings "
136 "(full 30-day coverage)"
137 ),
138 fix_cli="X_BEARER_TOKEN=<your-x-api-bearer-token>",
139 anchor="grok-bot",
140 ),
141 _entry(
142 "x", "payment_required",
143 cause="X API credits are exhausted (HTTP 402)",
144 fix_nl=(
145 "top up X API credits in the X developer console, or connect X in "
146 "Grok Bot settings (full 30-day coverage)"
147 ),
148 fix_cli="X_BEARER_TOKEN=<bearer-from-a-project-with-credits>",
149 anchor="grok-bot",
150 ),
151 _entry(
152 "x", "connector_missing",
153 cause=(
154 "the X connector lane was declared but no connector result was "
155 "passed to the engine"
156 ),
157 fix_nl=(
158 "add the X for Grok Bot plugin and connect X in Grok Bot settings (full 30-day coverage) and pass the "
159 "connector's posts with --x-posts, or set X_BEARER_TOKEN from the X "
160 f"developer console ({BEARER_COVERAGE_NOTE})"
161 ),
162 fix_cli=f'{ENGINE_CLI} "<topic>" --x-posts <path-to-x-posts.json>',
163 anchor="grok-bot",
164 ),
165 _entry(
166 "scrapecreators", "key_missing",
167 cause="SCRAPECREATORS_API_KEY is not set",
168 fix_nl=(
169 "ask the agent to run setup with the GitHub device flow "
170 "(free 10,000-call signup; the key is persisted automatically)"
171 ),
172 fix_cli=SETUP_GITHUB_CLI,
173 anchor="api-keys-env",
174 ),
175 _entry(
176 "bluesky", "app_password_missing",
177 cause="BSKY_HANDLE and/or BSKY_APP_PASSWORD are not set",
178 fix_nl=(
179 "generate an app password at bsky.app/settings/app-passwords and "
180 "add BSKY_HANDLE plus BSKY_APP_PASSWORD to ~/.config/last30days/.env"
181 ),
182 fix_cli="BSKY_HANDLE=<your-handle> BSKY_APP_PASSWORD=<xxxx-xxxx-xxxx-xxxx>",
183 anchor="bluesky-app-password-format-and-search-host",
184 ),
185 _entry(
186 "youtube", "transcription_key_missing",
187 cause=(
188 "no transcription provider key for the caption-free transcript "
189 "backstop (GROQ_API_KEY or OPENAI_API_KEY)"
190 ),
191 fix_nl=(
192 "add a free Groq key from console.groq.com to "
193 "~/.config/last30days/.env so caption-free videos still get "
194 "transcripts (OPENAI_API_KEY also works as the paid backstop)"
195 ),
196 fix_cli="GROQ_API_KEY=<your-groq-key>",
197 anchor="api-keys-env",
198 ),
199 _entry(
200 "digg", "pp_cli_missing",
201 cause="digg-pp-cli is not installed",
202 fix_nl=(
203 "install the Digg CLI through the Printing Press library, then "
204 "re-run setup so the source activates"
205 ),
206 fix_cli=_DIGG_PP_INSTALL_CLI,
207 anchor="first-run-onboarding",
208 ),
209 _entry(
210 "digg", "pp_cli_broken",
211 cause=(
212 "digg-pp-cli resolves on PATH but won't execute (broken or "
213 "hanging binary left behind by a bad install)"
214 ),
215 fix_nl=(
216 "reinstall the Digg CLI (re-run the Printing Press install) so "
217 "the binary actually executes; it is installed but not serving"
218 ),
219 fix_cli=_DIGG_PP_INSTALL_CLI,
220 anchor="first-run-onboarding",
221 ),
222 _entry(
223 "digg", "pp_cli_off_path",
224 cause=(
225 "digg-pp-cli is installed but its directory is not on the "
226 "agent-subprocess PATH"
227 ),
228 fix_nl=(
229 "add the install directory (default ~/.local/bin) to the PATH the "
230 "agent subprocess uses; the engine gate only activates the source "
231 "when the binary resolves on PATH"
232 ),
233 fix_cli='export PATH="$HOME/.local/bin:$PATH"',
234 anchor="first-run-onboarding",
235 ),
236 _entry(
237 "youtube", "ytdlp_missing",
238 cause="yt-dlp is not installed on the agent-subprocess PATH",
239 fix_nl="install yt-dlp to enable the free local YouTube lane",
240 fix_cli=_YTDLP_BREW_INSTALL,
241 alt_cli=("scoop install yt-dlp", "pip install -U yt-dlp"),
242 ),
243 _entry(
244 "youtube", "ytdlp_stale",
245 cause=(
246 "yt-dlp is installed but stale: YouTube's caption format changes "
247 "frequently and old binaries silently fail every transcript"
248 ),
249 fix_nl="update yt-dlp via your package manager",
250 fix_cli="brew upgrade yt-dlp",
251 alt_cli=("scoop update yt-dlp", "pip install -U yt-dlp"),
252 ),
253 _entry(
254 "youtube", "ytdlp_broken",
255 cause=(
256 "yt-dlp resolves on PATH but won't execute (the stale-shim class: "
257 "a wrapper left behind by an interpreter upgrade)"
258 ),
259 fix_nl=(
260 "reinstall yt-dlp so the binary actually executes; a plain "
261 "install reads as a no-op because the broken shim is still present"
262 ),
263 fix_cli=_YTDLP_BREW_REINSTALL,
264 alt_cli=(_YTDLP_PIPX_REINSTALL,),
265 ),
266 _entry(
267 "truthsocial", "token_missing",
268 cause="TRUTHSOCIAL_TOKEN is not set",
269 fix_nl=(
270 "log into truthsocial.com in your browser and let setup read the "
271 "session cookie, or copy the bearer token from your browser's dev "
272 "tools into ~/.config/last30days/.env"
273 ),
274 fix_cli=SETUP_BROWSER_COOKIES_CLI,
275 anchor="api-keys-env",
276 ),
277 _entry(
278 "xiaohongshu", "service_unreachable",
279 cause=(
280 "Xiaohongshu browser-session service is unreachable or not logged "
281 "in; last30days auto-probes http://localhost:18060 and "
282 "http://host.docker.internal:18060 unless XIAOHONGSHU_API_BASE is set"
283 ),
284 fix_nl=(
285 "start a local x-mcp browser plugin or xpzouying/xiaohongshu-mcp "
286 "service that can see your logged-in Xiaohongshu browser session; "
287 "set XIAOHONGSHU_API_BASE only when it runs on a custom host/port"
288 ),
289 fix_cli="XIAOHONGSHU_API_BASE=http://your-host:18060 # only for a custom host; leave unset to auto-probe localhost and host.docker.internal",
290 anchor="api-keys-env",
291 ),
292 ))
293
294
295 def lookup(source: str, failure: str) -> Optional[Prescription]:
296 """Return the registered entry for (source, failure), or None."""
297 return REGISTRY.get((source, failure))
298
299
300 # Failure names remapped on an official-only host (env.x_policy): every
301 # cookie or Grok CLI failure has an official-path counterpart, so no fix
302 # line there names cookies, the scraper, or the CLI. Official names
303 # pass through unchanged on every host.
304 _OFFICIAL_X_FAILURES: Dict[str, str] = {
305 "cookies_missing": "bearer_missing",
306 "cookies_expired": "bearer_invalid",
307 "grok_cli_missing": "bearer_missing",
308 "grok_not_authenticated": "bearer_missing",
309 }
310
311
312 def for_x(config: dict, failure: str) -> Prescription:
313 """Policy-aware X prescription.
314
315 On an official-only host (``env.x_policy(config).hint_namespace ==
316 "official"``) a default-namespace failure resolves to its official
317 counterpart; elsewhere today's entry is returned unchanged. Never
318 raises (falls through to ``get``'s generic fallback).
319 """
320 if env.x_policy(config or {}).hint_namespace == "official":
321 failure = _OFFICIAL_X_FAILURES.get(failure, failure)
322 return get("x", failure)
323
324
325 def get(source: str, failure: str) -> Prescription:
326 """Return the registered entry, or the generic CONFIGURATION.md fallback.
327
328 Never raises: an unregistered failure mode still yields an actionable
329 (if generic) prescription, so a report renderer cannot crash on a
330 failure class the registry has not learned yet.
331 """
332 entry = lookup(source, failure)
333 if entry is not None:
334 return entry
335 return Prescription(
336 source=source,
337 failure=failure,
338 cause=f"{source}: {failure.replace('_', ' ')}",
339 fix_nl=GENERIC_FIX_NL,
340 fix_cli=f"{ENGINE_CLI} setup",
341 )
342
343
344 # ---------------------------------------------------------------------------
345 # Composition with U1 dependency probes
346 # ---------------------------------------------------------------------------
347
348 def _dependency_failure(probe: health.DependencyProbe) -> Optional[Tuple[str, str]]:
349 """Map a failed dependency probe onto a registered (source, failure)."""
350 if probe.name == "yt-dlp":
351 if probe.status == health.MISSING:
352 return ("youtube", "ytdlp_missing")
353 return ("youtube", "ytdlp_broken") # BROKEN and TIMEOUT: reinstall class
354 if probe.name == "digg-pp-cli":
355 # health reports off-PATH binaries as MISSING with ``off_path=True``;
356 # the distinction only picks cause/NL wording — the probe's own
357 # prescription wins the CLI form either way.
358 if probe.status == health.MISSING:
359 if probe.off_path:
360 return ("digg", "pp_cli_off_path")
361 return ("digg", "pp_cli_missing")
362 return ("digg", "pp_cli_broken") # BROKEN and TIMEOUT: reinstall class
363 return None
364
365
366 def for_dependency_probe(probe: health.DependencyProbe) -> Optional[Prescription]:
367 """Prescription for a failed U1 dependency probe (None when OK).
368
369 U1's machine-aware prescription (the manager that owns the binary on
370 THIS machine, or a PATH edit for off-PATH installs) wins the CLI form;
371 the registry entry supplies the shared cause/NL/anchor vocabulary.
372 Unregistered dependencies wrap the probe so callers still get both
373 fix forms without this module restating U1's strings.
374 """
375 if probe.ok:
376 return None
377 key = _dependency_failure(probe)
378 entry = REGISTRY.get(key) if key else None
379 if entry is None:
380 return Prescription(
381 source=probe.name,
382 failure=probe.status,
383 cause=probe.detail or f"{probe.name}: {probe.status}",
384 fix_nl=f"repair the {probe.name} install; {GENERIC_FIX_NL}",
385 fix_cli=probe.prescription or f"{ENGINE_CLI} setup",
386 )
387 updates = {}
388 if probe.detail:
389 updates["cause"] = probe.detail
390 if probe.prescription and probe.prescription != entry.fix_cli:
391 updates["fix_cli"] = probe.prescription
392 return replace(entry, **updates) if updates else entry
393
393 lines PYTHON