返回 last30days-skill
backends.py
根目录 / skills / last30days / scripts / lib / backends.py
1 """Backend-chain descriptors with predicted selection (doctor, R4).
2
3 Chained sources declare their routing here ONCE — imported from the
4 definitions ``lib/env.py`` already owns (chain order, pin var names) — and
5 ``resolve()`` turns side-effect-free probes into a truthful prediction of
6 what the next run will do.
7
8 Two resolution modes:
9
10 - ``alternative`` (X, YouTube, web search): the pipeline tries genuinely
11 interchangeable backends in a declared order. Resolution probes ALL
12 candidates first, then picks (collect-then-pick): the first fully-usable
13 backend wins the "will use" prediction; otherwise the best degraded
14 candidate resolves with a warn tier; otherwise the source is an error
15 carrying the highest-priority backend's prescription. Collecting before
16 picking prevents an installed-but-unauthenticated preferred backend from
17 shadowing a fully working fallback.
18
19 - ``conditional`` (Reddit): routing is per-query and outcome-dependent —
20 public keyless composite by default, ScrapeCreators backfill only when
21 results fall below the configured thinness floor (see the gating in
22 ``lib/pipeline.py``). No probe can pick one winner, so resolution renders
23 honest conditional wording instead of an ``active_backend``. Reddit's
24 internal keyless lanes (rss/listing/arctic/shreddit) are sub-probe detail
25 inside the public composite, never chain entries.
26
27 ``active_backend`` semantics: a PREDICTION — "the first backend the probes
28 say the next run will try" — rendered as "will use". It is not an
29 observation of what served a past run, and runtime failover can still
30 diverge mid-run (a present-but-expired paid key passes a presence probe).
31
32 Paid lanes (xai, xquik, serper, and every other API-key backend, including
33 ScrapeCreators) probe KEY PRESENCE ONLY: a dict lookup, never a network
34 call or credential spend. Binary-backed lanes reuse the U1 dependency
35 probe layer (``health.probe_dependency``) so a stale shim reads as BROKEN,
36 not available (#692).
37
38 This module observes and predicts only. It must never alter which backend
39 the pipeline actually uses; parity with the pipeline's pre-failover
40 selection is asserted in ``tests/test_backend_descriptors.py``.
41 """
42
43 from __future__ import annotations
44
45 from dataclasses import dataclass
46 from shutil import which
47 from typing import Any, Callable, Dict, List, Optional, Tuple
48
49 from . import env, health, prescriptions
50
51 # Resolution modes.
52 MODE_ALTERNATIVE = "alternative" # probe-ordered chain, first-usable wins
53 MODE_CONDITIONAL = "conditional" # per-query routing; wording, never a winner
54
55 # Rollup tiers for a resolved chain (doctor maps these into its R1 table).
56 TIER_OK = "ok"
57 TIER_WARN = "warn"
58 TIER_ERROR = "error"
59
60 # Web search backend order. grounding.web_search's auto branch owns the
61 # runtime behavior (brave -> exa -> serper -> parallel -> keyless floor);
62 # there is no importable constant there, so this declaration is guarded by
63 # the grounding-auto parity test rather than an import.
64 WEB_BACKEND_ORDER: Tuple[str, ...] = ("brave", "exa", "serper", "parallel", "keyless")
65
66 # YouTube backend order (pipeline: yt-dlp first, ScrapeCreators search
67 # fallback when yt-dlp is absent or fails — see lib/pipeline.py).
68 YOUTUBE_BACKEND_ORDER: Tuple[str, ...] = ("yt-dlp", "scrapecreators")
69
70 # Chain-failure fixes embed the registry's CLI forms (KTD 7): the command a
71 # backend finding prescribes and the one doctor/quality-nudge render for the
72 # same failure mode come from one entry and cannot drift.
73 _SC_PRESCRIPTION = (
74 "set SCRAPECREATORS_API_KEY (free 10,000-call signup: "
75 f"{prescriptions.get('scrapecreators', 'key_missing').fix_cli})"
76 )
77 _X_COOKIES_PRESCRIPTION = (
78 "run setup with browser-cookie consent: "
79 f"{prescriptions.get('x', 'cookies_missing').fix_cli}"
80 )
81
82
83 @dataclass
84 class BackendFinding:
85 """Side-effect-free probe outcome for one backend of a chained source.
86
87 ``status`` uses the ``lib.health`` vocabulary (OK/DEGRADED/MISSING/
88 BROKEN/TIMEOUT/ERROR). ``prescription`` is the fix when non-OK.
89 ``requires`` is the backend's requirement note for report rendering.
90 """
91
92 name: str
93 status: str
94 detail: str = ""
95 prescription: str = ""
96 requires: str = ""
97
98 @property
99 def usable(self) -> bool:
100 """Fully or partially usable (OK/DEGRADED) — eligible for selection."""
101 return self.status in (health.OK, health.DEGRADED)
102
103
104 @dataclass(frozen=True)
105 class BackendSpec:
106 """One backend in a chain: name, probe, requirement note, paid flag.
107
108 ``probe`` must be side-effect-free. When ``paid`` is True the probe is
109 key-presence only: no subprocess, no network, no credential spend.
110 """
111
112 name: str
113 requires: str
114 probe: Callable[[Dict[str, Any]], "BackendFinding"]
115 paid: bool = False
116
117
118 @dataclass(frozen=True)
119 class ChainDescriptor:
120 """A chained source's declared routing: backends, mode, and pin knob."""
121
122 source: str
123 mode: str
124 backends: Tuple[BackendSpec, ...]
125 pin_var: Optional[str] = None # env var pin (X, Reddit)
126 pin_flag: Optional[str] = None # CLI flag pin (web: --web-backend)
127
128
129 @dataclass
130 class BackendResolution:
131 """Resolved routing for one chained source.
132
133 ``active_backend`` is the will-use PREDICTION for alternative chains
134 and always None for conditional mode (Reddit never gets a computed
135 winner — ``conditional`` carries the honest wording instead).
136 """
137
138 source: str
139 mode: str
140 chain: List[str]
141 findings: List[BackendFinding]
142 active_backend: Optional[str] = None
143 tier: str = TIER_OK
144 pinned: bool = False
145 pin: Optional[str] = None
146 prescription: str = ""
147 conditional: str = ""
148
149 @property
150 def summary(self) -> str:
151 """One-line rendering: will-use prediction or conditional wording."""
152 if self.mode == MODE_CONDITIONAL:
153 return self.conditional
154 if self.active_backend is None:
155 line = f"no usable backend (chain: {' -> '.join(self.chain)})"
156 if self.prescription:
157 line += f"; fix: {self.prescription}"
158 return line
159 line = f"will use: {self.active_backend}"
160 if self.pinned:
161 line += f" (pinned via {self._pin_origin()})"
162 return line
163
164 def _pin_origin(self) -> str:
165 d = DESCRIPTORS.get(self.source)
166 if d is None:
167 return "pin"
168 return d.pin_var or d.pin_flag or "pin"
169
170
171 # ---------------------------------------------------------------------------
172 # Probes. All side-effect-free; paid lanes are pure dict lookups.
173 # ---------------------------------------------------------------------------
174
175 def _key_probe(name: str, key_var: str, requires: str, note: str = "") -> Callable:
176 """Key-presence probe for a paid API lane. Never touches the network."""
177
178 def probe(config: Dict[str, Any]) -> BackendFinding:
179 if config.get(key_var):
180 return BackendFinding(
181 name=name,
182 status=health.OK,
183 detail=f"{key_var} present",
184 requires=requires,
185 )
186 prescription = note or f"set {key_var} in ~/.config/last30days/.env"
187 return BackendFinding(
188 name=name,
189 status=health.MISSING,
190 detail=f"{key_var} not set",
191 prescription=prescription,
192 requires=requires,
193 )
194
195 return probe
196
197
198 def _probe_bird(config: Dict[str, Any]) -> BackendFinding:
199 """Bird = vendored X GraphQL client (node script) + browser-cookie creds.
200
201 Cookie presence is checked FIRST, mirroring ``env._x_backend_available``'s
202 gating (``has_bird_creds and is_bird_installed()``): without cookies bird
203 is unconfigured regardless of node/script state, and the fix is the
204 cookie-consent flow — a broken node runtime must not turn an unconfigured
205 backend into an error carrying a node prescription.
206 """
207 from . import bird_x
208
209 requires = "X browser cookies (AUTH_TOKEN/CT0) + node"
210 if not (config.get("AUTH_TOKEN") and config.get("CT0")):
211 return BackendFinding(
212 name="bird",
213 status=health.MISSING,
214 detail="X browser cookies (AUTH_TOKEN/CT0) not configured",
215 prescription=_X_COOKIES_PRESCRIPTION,
216 requires=requires,
217 )
218 if not bird_x.is_bird_installed():
219 # Distinguish a missing/broken node runtime from a missing script.
220 node = health.probe_dependency("node")
221 if node.status != health.OK:
222 return BackendFinding(
223 name="bird",
224 status=node.status,
225 detail=node.detail,
226 prescription=node.prescription,
227 requires=requires,
228 )
229 return BackendFinding(
230 name="bird",
231 status=health.MISSING,
232 detail="vendored bird-search client not found",
233 prescription="reinstall the skill (npx skills add . -g -y) to restore lib/vendor/bird-search",
234 requires=requires,
235 )
236 node = health.probe_dependency("node")
237 if node.status != health.OK:
238 # Resolvable-but-broken node (stale shim) must not read as usable.
239 return BackendFinding(
240 name="bird",
241 status=node.status,
242 detail=node.detail,
243 prescription=node.prescription,
244 requires=requires,
245 )
246 return BackendFinding(
247 name="bird",
248 status=health.OK,
249 detail="browser-cookie auth (AUTH_TOKEN/CT0) configured",
250 requires=requires,
251 )
252
253
254 def _probe_xurl(config: Dict[str, Any]) -> BackendFinding:
255 """xurl = official X API v2 CLI (OAuth2). Free lane; LOCAL-ONLY probe.
256
257 Doctor's no-network guarantee forbids the live ``xurl whoami`` check
258 (``xurl_x.is_available()`` — an authenticated X API call, reserved for
259 research time). This probe keys on local evidence instead: the binary
260 on PATH plus xurl's on-disk token store (~/.xurl). Stored credentials
261 read as OK with an explicit "not live-verified" caveat; an unreadable
262 token store is a typed ERROR (broken, not unconfigured).
263 """
264 from . import xurl_x
265
266 requires = "xurl CLI installed + OAuth2 login"
267 if which("xurl") is None:
268 return BackendFinding(
269 name="xurl",
270 status=health.MISSING,
271 detail="xurl CLI not found on PATH",
272 prescription="npm install -g xurl && xurl auth oauth2 login",
273 requires=requires,
274 )
275 store_status, store_detail = xurl_x.stored_auth_status()
276 if store_status == xurl_x.AUTH_OK:
277 return BackendFinding(
278 name="xurl",
279 status=health.OK,
280 detail=(
281 "installed; stored OAuth2 credentials present; "
282 "auth not live-verified (no network)"
283 ),
284 requires=requires,
285 )
286 if store_status == xurl_x.AUTH_ERROR:
287 return BackendFinding(
288 name="xurl",
289 status=health.ERROR,
290 detail=store_detail,
291 prescription="xurl auth oauth2 login",
292 requires=requires,
293 )
294 return BackendFinding(
295 name="xurl",
296 status=health.MISSING,
297 detail="xurl installed but not authenticated",
298 prescription="xurl auth oauth2 login",
299 requires=requires,
300 )
301
302
303 def _probe_ytdlp(config: Dict[str, Any]) -> BackendFinding:
304 """yt-dlp via the U1 dependency-probe layer (missing/broken/timeout)."""
305 dep = health.probe_dependency("yt-dlp")
306 return BackendFinding(
307 name="yt-dlp",
308 status=dep.status,
309 detail=dep.detail,
310 prescription=dep.prescription,
311 requires="yt-dlp on the agent-subprocess PATH",
312 )
313
314
315 def _probe_web_keyless(config: Dict[str, Any]) -> BackendFinding:
316 """The keyless web-search floor: works keyless, but degraded quality."""
317 requires = "no key; suppressed on native-search hosts"
318 if env.keyless_web_allowed(config):
319 return BackendFinding(
320 name="keyless",
321 status=health.DEGRADED,
322 detail="keyless search floor (no paid key; lower quality)",
323 requires=requires,
324 )
325 return BackendFinding(
326 name="keyless",
327 status=health.MISSING,
328 detail="keyless floor suppressed: host has native web search",
329 prescription="",
330 requires=requires,
331 )
332
333
334 def _probe_reddit_public(config: Dict[str, Any]) -> BackendFinding:
335 """Public keyless Reddit composite; internal lanes are sub-probe detail."""
336 return BackendFinding(
337 name="public",
338 status=health.OK,
339 detail="public keyless composite (lanes: rss, listing, arctic, shreddit)",
340 requires="none (public endpoints)",
341 )
342
343
344 # ---------------------------------------------------------------------------
345 # Registry: routing declared once, from env.py's definitions where they exist.
346 # ---------------------------------------------------------------------------
347
348 _X_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
349 "xai": _key_probe("xai", "XAI_API_KEY", "XAI_API_KEY (xAI/Grok live search)"),
350 "bird": _probe_bird,
351 "xurl": _probe_xurl,
352 "xquik": _key_probe("xquik", "XQUIK_API_KEY", "XQUIK_API_KEY (xquik.com)"),
353 }
354 _X_PAID = {"xai", "xquik"}
355
356 _WEB_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
357 "brave": _key_probe("brave", "BRAVE_API_KEY", "BRAVE_API_KEY"),
358 "exa": _key_probe("exa", "EXA_API_KEY", "EXA_API_KEY"),
359 "serper": _key_probe("serper", "SERPER_API_KEY", "SERPER_API_KEY"),
360 "parallel": _key_probe("parallel", "PARALLEL_API_KEY", "PARALLEL_API_KEY"),
361 "keyless": _probe_web_keyless,
362 }
363 _WEB_KEYED = {"brave", "exa", "serper", "parallel"}
364
365 _SC_SPEC = BackendSpec(
366 name="scrapecreators",
367 requires="SCRAPECREATORS_API_KEY",
368 probe=_key_probe(
369 "scrapecreators", "SCRAPECREATORS_API_KEY", "SCRAPECREATORS_API_KEY",
370 note=_SC_PRESCRIPTION,
371 ),
372 paid=True,
373 )
374
375 DESCRIPTORS: Dict[str, ChainDescriptor] = {
376 # X: chain order and pin var imported from env.py (single source of truth).
377 "x": ChainDescriptor(
378 source="x",
379 mode=MODE_ALTERNATIVE,
380 backends=tuple(
381 BackendSpec(
382 name=name,
383 requires={
384 "xai": "XAI_API_KEY (xAI/Grok live search)",
385 "bird": "X browser cookies (AUTH_TOKEN/CT0) + node",
386 "xurl": "xurl CLI installed + OAuth2 login",
387 "xquik": "XQUIK_API_KEY (xquik.com)",
388 }[name],
389 probe=_X_PROBES[name],
390 paid=name in _X_PAID,
391 )
392 for name in env.X_BACKEND_ORDER
393 ),
394 pin_var=env.X_BACKEND_PIN_VAR,
395 ),
396 "youtube": ChainDescriptor(
397 source="youtube",
398 mode=MODE_ALTERNATIVE,
399 backends=(
400 BackendSpec(
401 name="yt-dlp",
402 requires="yt-dlp on the agent-subprocess PATH",
403 probe=_probe_ytdlp,
404 ),
405 _SC_SPEC,
406 ),
407 pin_var=None, # no YouTube pin knob exists
408 ),
409 "web": ChainDescriptor(
410 source="web",
411 mode=MODE_ALTERNATIVE,
412 backends=tuple(
413 BackendSpec(
414 name=name,
415 requires=(f"{name.upper()}_API_KEY" if name in _WEB_KEYED
416 else "no key; suppressed on native-search hosts"),
417 probe=_WEB_PROBES[name],
418 paid=name in _WEB_KEYED,
419 )
420 for name in WEB_BACKEND_ORDER
421 ),
422 pin_var=None, # pinned per-run via --web-backend, not an env var
423 pin_flag="--web-backend",
424 ),
425 "reddit": ChainDescriptor(
426 source="reddit",
427 mode=MODE_CONDITIONAL,
428 backends=(
429 BackendSpec(
430 name="public",
431 requires="none (public endpoints)",
432 probe=_probe_reddit_public,
433 ),
434 _SC_SPEC,
435 ),
436 pin_var=env.REDDIT_BACKEND_PIN_VAR,
437 ),
438 }
439
440
441 def get_descriptor(source: str) -> ChainDescriptor:
442 """Return the declared routing descriptor for ``source`` (KeyError if none)."""
443 return DESCRIPTORS[source]
444
445
446 # ---------------------------------------------------------------------------
447 # Resolution
448 # ---------------------------------------------------------------------------
449
450 def resolve(
451 source: str,
452 config: Dict[str, Any],
453 pin: Optional[str] = None,
454 ) -> BackendResolution:
455 """Resolve a chained source's routing into a truthful prediction.
456
457 ``pin`` is an explicit per-run pin (the ``--web-backend`` flag); it
458 takes precedence over the descriptor's env pin var. ``"auto"``/None
459 mean unpinned. Probing is side-effect-free and collect-then-pick.
460
461 Time budget: backends are probed sequentially, so a chain's budget is
462 ADDITIVE across its backends — each binary-backed probe is bounded by
463 ``health.PROBE_TIMEOUT`` and paid/key lanes are dict lookups that cost
464 nothing, giving a worst case of roughly (binary probes in the chain) x
465 ``health.PROBE_TIMEOUT``. Deliberately no intra-chain concurrency:
466 probes are memoized per process and the worst case only occurs when
467 multiple binaries are simultaneously hung.
468 """
469 descriptor = get_descriptor(source)
470 findings = [
471 _run_probe(spec, config) for spec in descriptor.backends
472 ]
473 if descriptor.mode == MODE_CONDITIONAL:
474 return _resolve_conditional(descriptor, config, findings)
475 return _resolve_alternative(descriptor, config, findings, pin)
476
477
478 def _run_probe(spec: BackendSpec, config: Dict[str, Any]) -> BackendFinding:
479 """Run one probe, isolating failures so one bad probe can't blank a chain."""
480 try:
481 finding = spec.probe(config)
482 except Exception as exc: # a probe bug must not take the report down
483 finding = BackendFinding(
484 name=spec.name,
485 status=health.ERROR,
486 detail=f"probe failed: {type(exc).__name__}: {exc}",
487 requires=spec.requires,
488 )
489 if not finding.requires:
490 finding.requires = spec.requires
491 return finding
492
493
494 def _resolve_alternative(
495 descriptor: ChainDescriptor,
496 config: Dict[str, Any],
497 findings: List[BackendFinding],
498 pin: Optional[str],
499 ) -> BackendResolution:
500 names = [spec.name for spec in descriptor.backends]
501 by_name = {f.name: f for f in findings}
502 res = BackendResolution(
503 source=descriptor.source,
504 mode=MODE_ALTERNATIVE,
505 chain=list(names),
506 findings=findings,
507 )
508
509 pin_name: Optional[str] = None
510 if pin and pin not in ("auto", "none") and pin in by_name:
511 pin_name = pin
512 elif descriptor.pin_var:
513 raw = (config.get(descriptor.pin_var) or "").lower()
514 if raw in by_name:
515 pin_name = raw
516
517 if pin_name:
518 # A pin forces a single backend (no failover) — mirror
519 # env.x_backend_chain's pin semantics exactly.
520 res.pinned = True
521 res.pin = pin_name
522 finding = by_name[pin_name]
523 if finding.status == health.OK:
524 res.active_backend = pin_name
525 res.tier = TIER_OK
526 elif finding.status == health.DEGRADED:
527 res.active_backend = pin_name
528 res.tier = TIER_WARN
529 else:
530 res.tier = TIER_ERROR
531 res.prescription = finding.prescription or (
532 f"unpin {descriptor.pin_var or descriptor.pin_flag} or fix {pin_name}"
533 )
534 return res
535
536 # Collect-then-pick: first fully-usable wins; else best degraded; else
537 # error carrying the highest-priority backend's prescription.
538 for finding in findings:
539 if finding.status == health.OK:
540 res.active_backend = finding.name
541 res.tier = TIER_OK
542 return res
543 for finding in findings:
544 if finding.status == health.DEGRADED:
545 res.active_backend = finding.name
546 res.tier = TIER_WARN
547 return res
548 res.tier = TIER_ERROR
549 res.prescription = findings[0].prescription if findings else ""
550 return res
551
552
553 def _reddit_sc_min_items(config: Dict[str, Any]) -> int:
554 """The thinness floor, parsed exactly as the pipeline parses it
555 (lib/pipeline.py reddit fetch: int(... or 0), malformed -> 0)."""
556 try:
557 return int(config.get(env.REDDIT_SC_MIN_ITEMS_VAR) or 0)
558 except (TypeError, ValueError):
559 return 0
560
561
562 def _resolve_conditional(
563 descriptor: ChainDescriptor,
564 config: Dict[str, Any],
565 findings: List[BackendFinding],
566 ) -> BackendResolution:
567 """Reddit: render the real per-query semantics, never a computed winner."""
568 res = BackendResolution(
569 source=descriptor.source,
570 mode=MODE_CONDITIONAL,
571 chain=[spec.name for spec in descriptor.backends],
572 findings=findings,
573 active_backend=None, # conditional mode never picks a winner
574 tier=TIER_OK, # the public keyless composite is always reachable
575 )
576 has_key = bool(config.get("SCRAPECREATORS_API_KEY"))
577 raw_pin = (config.get(descriptor.pin_var) or "").lower() if descriptor.pin_var else ""
578 pinned_sc = has_key and raw_pin == "scrapecreators"
579 floor = _reddit_sc_min_items(config)
580
581 if pinned_sc:
582 res.pinned = True
583 res.pin = "scrapecreators"
584 res.conditional = (
585 f"ScrapeCreators primary (pinned via {descriptor.pin_var}); "
586 "public keyless composite fallback"
587 )
588 return res
589
590 if has_key:
591 if floor > 0:
592 backfill = (
593 f"ScrapeCreators backfill when results fall below the "
594 f"{floor}-item floor"
595 )
596 else:
597 backfill = "ScrapeCreators backfill when the free path returns nothing"
598 res.conditional = f"public keyless composite (default); {backfill}"
599 return res
600
601 res.conditional = "public keyless composite (default); no ScrapeCreators key for backfill"
602 if raw_pin == "scrapecreators":
603 # The pipeline ignores the pin without a key; say so honestly.
604 res.conditional += (
605 f" ({descriptor.pin_var} pin ignored: SCRAPECREATORS_API_KEY not set)"
606 )
607 return res
608
608 lines PYTHON