返回 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 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
296 lines PYTHON