| 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 |