40b42b7bed
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
701 lines
26 KiB
Python
701 lines
26 KiB
Python
"""Backend-chain descriptors with predicted selection (doctor, R4).
|
|
|
|
Chained sources declare their routing here ONCE — imported from the
|
|
definitions ``lib/env.py`` already owns (chain order, pin var names) — and
|
|
``resolve()`` turns side-effect-free probes into a truthful prediction of
|
|
what the next run will do.
|
|
|
|
Two resolution modes:
|
|
|
|
- ``alternative`` (X, YouTube, web search): the pipeline tries genuinely
|
|
interchangeable backends in a declared order. Resolution probes ALL
|
|
candidates first, then picks (collect-then-pick): the first fully-usable
|
|
backend wins the "will use" prediction; otherwise the best degraded
|
|
candidate resolves with a warn tier; otherwise the source is an error
|
|
carrying the highest-priority backend's prescription. Collecting before
|
|
picking prevents an installed-but-unauthenticated preferred backend from
|
|
shadowing a fully working fallback.
|
|
|
|
- ``conditional`` (Reddit): routing is per-query and outcome-dependent —
|
|
public keyless composite by default, ScrapeCreators backfill only when
|
|
results fall below the configured thinness floor (see the gating in
|
|
``lib/pipeline.py``). No probe can pick one winner, so resolution renders
|
|
honest conditional wording instead of an ``active_backend``. Reddit's
|
|
internal keyless lanes (rss/listing/arctic/shreddit) are sub-probe detail
|
|
inside the public composite, never chain entries.
|
|
|
|
``active_backend`` semantics: a PREDICTION — "the first backend the probes
|
|
say the next run will try" — rendered as "will use". It is not an
|
|
observation of what served a past run, and runtime failover can still
|
|
diverge mid-run (a present-but-expired paid key passes a presence probe).
|
|
|
|
Paid lanes (xai, xquik, serper, and every other API-key backend, including
|
|
ScrapeCreators) probe KEY PRESENCE ONLY: a dict lookup, never a network
|
|
call or credential spend. Binary-backed lanes reuse the U1 dependency
|
|
probe layer (``health.probe_dependency``) so a stale shim reads as BROKEN,
|
|
not available (#692).
|
|
|
|
This module observes and predicts only. It must never alter which backend
|
|
the pipeline actually uses; parity with the pipeline's pre-failover
|
|
selection is asserted in ``tests/test_backend_descriptors.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from shutil import which
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from . import env, health, prescriptions
|
|
|
|
# Resolution modes.
|
|
MODE_ALTERNATIVE = "alternative" # probe-ordered chain, first-usable wins
|
|
MODE_CONDITIONAL = "conditional" # per-query routing; wording, never a winner
|
|
|
|
# Rollup tiers for a resolved chain (doctor maps these into its R1 table).
|
|
TIER_OK = "ok"
|
|
TIER_WARN = "warn"
|
|
TIER_ERROR = "error"
|
|
|
|
# Web search backend order. grounding.web_search's auto branch owns the
|
|
# runtime behavior (brave -> exa -> serper -> parallel -> keyless floor);
|
|
# there is no importable constant there, so this declaration is guarded by
|
|
# the grounding-auto parity test rather than an import.
|
|
WEB_BACKEND_ORDER: Tuple[str, ...] = ("brave", "exa", "serper", "parallel", "keyless")
|
|
|
|
# YouTube backend order (pipeline: yt-dlp first, ScrapeCreators search
|
|
# fallback when yt-dlp is absent or fails — see lib/pipeline.py).
|
|
YOUTUBE_BACKEND_ORDER: Tuple[str, ...] = ("yt-dlp", "scrapecreators")
|
|
|
|
# Chain-failure fixes embed the registry's CLI forms (KTD 7): the command a
|
|
# backend finding prescribes and the one doctor/quality-nudge render for the
|
|
# same failure mode come from one entry and cannot drift.
|
|
_SC_PRESCRIPTION = (
|
|
"set SCRAPECREATORS_API_KEY (free 10,000-call signup: "
|
|
f"{prescriptions.get('scrapecreators', 'key_missing').fix_cli})"
|
|
)
|
|
_X_COOKIES_PRESCRIPTION = (
|
|
"run setup with browser-cookie consent: "
|
|
f"{prescriptions.get('x', 'cookies_missing').fix_cli}"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class BackendFinding:
|
|
"""Side-effect-free probe outcome for one backend of a chained source.
|
|
|
|
``status`` uses the ``lib.health`` vocabulary (OK/DEGRADED/MISSING/
|
|
BROKEN/TIMEOUT/ERROR). ``prescription`` is the fix when non-OK.
|
|
``requires`` is the backend's requirement note for report rendering.
|
|
"""
|
|
|
|
name: str
|
|
status: str
|
|
detail: str = ""
|
|
prescription: str = ""
|
|
requires: str = ""
|
|
|
|
@property
|
|
def usable(self) -> bool:
|
|
"""Fully or partially usable (OK/DEGRADED) — eligible for selection."""
|
|
return self.status in (health.OK, health.DEGRADED)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackendSpec:
|
|
"""One backend in a chain: name, probe, requirement note, paid flag.
|
|
|
|
``probe`` must be side-effect-free. When ``paid`` is True the probe is
|
|
key-presence only: no subprocess, no network, no credential spend.
|
|
``opt_in`` marks backends that are never auto-selected and require an
|
|
explicit pin (grok).
|
|
"""
|
|
|
|
name: str
|
|
requires: str
|
|
probe: Callable[[Dict[str, Any]], "BackendFinding"]
|
|
paid: bool = False
|
|
opt_in: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChainDescriptor:
|
|
"""A chained source's declared routing: backends, mode, and pin knob."""
|
|
|
|
source: str
|
|
mode: str
|
|
backends: Tuple[BackendSpec, ...]
|
|
pin_var: Optional[str] = None # env var pin (X, Reddit)
|
|
pin_flag: Optional[str] = None # CLI flag pin (web: --web-backend)
|
|
|
|
|
|
@dataclass
|
|
class BackendResolution:
|
|
"""Resolved routing for one chained source.
|
|
|
|
``active_backend`` is the will-use PREDICTION for alternative chains
|
|
and always None for conditional mode (Reddit never gets a computed
|
|
winner — ``conditional`` carries the honest wording instead).
|
|
"""
|
|
|
|
source: str
|
|
mode: str
|
|
chain: List[str]
|
|
findings: List[BackendFinding]
|
|
active_backend: Optional[str] = None
|
|
tier: str = TIER_OK
|
|
pinned: bool = False
|
|
pin: Optional[str] = None
|
|
prescription: str = ""
|
|
conditional: str = ""
|
|
|
|
@property
|
|
def summary(self) -> str:
|
|
"""One-line rendering: will-use prediction or conditional wording."""
|
|
if self.mode == MODE_CONDITIONAL:
|
|
return self.conditional
|
|
if self.active_backend is None:
|
|
line = f"no usable backend (chain: {' -> '.join(self.chain)})"
|
|
if self.prescription:
|
|
line += f"; fix: {self.prescription}"
|
|
return line
|
|
line = f"will use: {self.active_backend}"
|
|
if self.pinned:
|
|
line += f" (pinned via {self._pin_origin()})"
|
|
return line
|
|
|
|
def _pin_origin(self) -> str:
|
|
d = DESCRIPTORS.get(self.source)
|
|
if d is None:
|
|
return "pin"
|
|
return d.pin_var or d.pin_flag or "pin"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Probes. All side-effect-free; paid lanes are pure dict lookups.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _key_probe(name: str, key_var: str, requires: str, note: str = "") -> Callable:
|
|
"""Key-presence probe for a paid API lane. Never touches the network."""
|
|
|
|
def probe(config: Dict[str, Any]) -> BackendFinding:
|
|
if config.get(key_var):
|
|
return BackendFinding(
|
|
name=name,
|
|
status=health.OK,
|
|
detail=f"{key_var} present",
|
|
requires=requires,
|
|
)
|
|
prescription = note or f"set {key_var} in ~/.config/last30days/.env"
|
|
return BackendFinding(
|
|
name=name,
|
|
status=health.MISSING,
|
|
detail=f"{key_var} not set",
|
|
prescription=prescription,
|
|
requires=requires,
|
|
)
|
|
|
|
return probe
|
|
|
|
|
|
def _probe_bird(config: Dict[str, Any]) -> BackendFinding:
|
|
"""Bird = vendored X GraphQL client (node script) + browser-cookie creds.
|
|
|
|
Cookie presence is checked FIRST, mirroring ``env._x_backend_available``'s
|
|
gating (``has_bird_creds and is_bird_installed()``): without cookies bird
|
|
is unconfigured regardless of node/script state, and the fix is the
|
|
cookie-consent flow — a broken node runtime must not turn an unconfigured
|
|
backend into an error carrying a node prescription.
|
|
"""
|
|
from . import bird_x
|
|
|
|
requires = "X browser cookies (AUTH_TOKEN/CT0) + node"
|
|
if not (config.get("AUTH_TOKEN") and config.get("CT0")):
|
|
return BackendFinding(
|
|
name="bird",
|
|
status=health.MISSING,
|
|
detail="X browser cookies (AUTH_TOKEN/CT0) not configured",
|
|
prescription=_X_COOKIES_PRESCRIPTION,
|
|
requires=requires,
|
|
)
|
|
if not bird_x.is_bird_installed():
|
|
# Distinguish a missing/broken node runtime from a missing script.
|
|
node = health.probe_dependency("node")
|
|
if node.status != health.OK:
|
|
return BackendFinding(
|
|
name="bird",
|
|
status=node.status,
|
|
detail=node.detail,
|
|
prescription=node.prescription,
|
|
requires=requires,
|
|
)
|
|
return BackendFinding(
|
|
name="bird",
|
|
status=health.MISSING,
|
|
detail="vendored bird-search client not found",
|
|
prescription="reinstall the skill (npx skills add . -g -y) to restore lib/vendor/bird-search",
|
|
requires=requires,
|
|
)
|
|
node = health.probe_dependency("node")
|
|
if node.status != health.OK:
|
|
# Resolvable-but-broken node (stale shim) must not read as usable.
|
|
return BackendFinding(
|
|
name="bird",
|
|
status=node.status,
|
|
detail=node.detail,
|
|
prescription=node.prescription,
|
|
requires=requires,
|
|
)
|
|
return BackendFinding(
|
|
name="bird",
|
|
status=health.OK,
|
|
detail="browser-cookie auth (AUTH_TOKEN/CT0) configured",
|
|
requires=requires,
|
|
)
|
|
|
|
|
|
def _probe_grok(config: Dict[str, Any]) -> BackendFinding:
|
|
"""grok CLI = keyless X. LOCAL-ONLY probe, like _probe_xurl.
|
|
|
|
Deliberately does NOT call ``health.probe_dependency``: that helper runs
|
|
``subprocess.run([name, "--version"])``, and the whole-doctor-path test
|
|
patches ``subprocess.run`` to raise.
|
|
|
|
Consequence to be honest about: a grok binary that resolves on PATH but
|
|
will not execute (the stale-shim class) reports OK here and fails only when
|
|
a real run shells out. ``grok_x.is_available`` does not close that gap
|
|
either -- it is also filesystem-only. ``health.probe_dependency("grok")``
|
|
is the executing probe, and it runs in doctor's CLI-health block rather
|
|
than on this no-subprocess path.
|
|
"""
|
|
from . import grok_x
|
|
|
|
requires = "grok CLI installed + signed in (no X credential)"
|
|
if which("grok") is None:
|
|
off_path = health._off_path_binary("grok")
|
|
if off_path is not None:
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.MISSING,
|
|
requires=requires,
|
|
detail=f"grok is installed at {off_path} but that directory is not on this process's PATH",
|
|
prescription=f'add {off_path.parent} to PATH (e.g. export PATH="{off_path.parent}:$PATH")',
|
|
)
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.MISSING,
|
|
requires=requires,
|
|
detail="grok CLI not found on PATH",
|
|
prescription=(
|
|
"install the Grok CLI: curl -fsSL https://x.ai/cli/install.sh | bash, "
|
|
"then run `grok login`"
|
|
),
|
|
)
|
|
store_status, store_detail, expires_at = grok_x.stored_auth_status()
|
|
if store_status == grok_x.AUTH_OK:
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.OK,
|
|
requires=requires,
|
|
detail=f"{store_detail} (not live-verified until a run)",
|
|
)
|
|
if store_status == grok_x.AUTH_EXPIRED:
|
|
expiry_str = expires_at.isoformat() if expires_at else "unknown"
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.DEGRADED,
|
|
requires=requires,
|
|
detail=(
|
|
f"Grok session expired at {expiry_str}; "
|
|
"refresh happens at run time (if revoked, run `grok login --device-auth`)"
|
|
),
|
|
prescription="grok login --device-auth",
|
|
)
|
|
if store_status == grok_x.AUTH_ERROR:
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.ERROR,
|
|
requires=requires,
|
|
detail=store_detail,
|
|
prescription="grok login",
|
|
)
|
|
return BackendFinding(
|
|
name="grok",
|
|
status=health.MISSING,
|
|
requires=requires,
|
|
detail="grok CLI installed but not signed in",
|
|
prescription="grok login",
|
|
)
|
|
|
|
|
|
def _probe_xurl(config: Dict[str, Any]) -> BackendFinding:
|
|
"""xurl = official X API v2 CLI (OAuth2). Free lane; LOCAL-ONLY probe.
|
|
|
|
Doctor's no-network guarantee forbids the live ``xurl whoami`` check
|
|
(``xurl_x.is_available()`` — an authenticated X API call, reserved for
|
|
research time). This probe keys on local evidence instead: the binary
|
|
on PATH plus xurl's on-disk token store (~/.xurl). Stored credentials
|
|
read as OK with an explicit "not live-verified" caveat; an unreadable
|
|
token store is a typed ERROR (broken, not unconfigured).
|
|
"""
|
|
from . import xurl_x
|
|
|
|
requires = "xurl CLI installed + OAuth2 login"
|
|
if which("xurl") is None:
|
|
return BackendFinding(
|
|
name="xurl",
|
|
status=health.MISSING,
|
|
detail="xurl CLI not found on PATH",
|
|
prescription="npm install -g xurl && xurl auth oauth2 login",
|
|
requires=requires,
|
|
)
|
|
store_status, store_detail = xurl_x.stored_auth_status()
|
|
if store_status == xurl_x.AUTH_OK:
|
|
return BackendFinding(
|
|
name="xurl",
|
|
status=health.OK,
|
|
detail=(
|
|
"installed; stored OAuth2 credentials present; "
|
|
"auth not live-verified (no network)"
|
|
),
|
|
requires=requires,
|
|
)
|
|
if store_status == xurl_x.AUTH_ERROR:
|
|
return BackendFinding(
|
|
name="xurl",
|
|
status=health.ERROR,
|
|
detail=store_detail,
|
|
prescription="xurl auth oauth2 login",
|
|
requires=requires,
|
|
)
|
|
return BackendFinding(
|
|
name="xurl",
|
|
status=health.MISSING,
|
|
detail="xurl installed but not authenticated",
|
|
prescription="xurl auth oauth2 login",
|
|
requires=requires,
|
|
)
|
|
|
|
|
|
def _probe_ytdlp(config: Dict[str, Any]) -> BackendFinding:
|
|
"""yt-dlp via the U1 dependency-probe layer (missing/broken/timeout)."""
|
|
dep = health.probe_dependency("yt-dlp")
|
|
return BackendFinding(
|
|
name="yt-dlp",
|
|
status=dep.status,
|
|
detail=dep.detail,
|
|
prescription=dep.prescription,
|
|
requires="yt-dlp on the agent-subprocess PATH",
|
|
)
|
|
|
|
|
|
def _probe_web_keyless(config: Dict[str, Any]) -> BackendFinding:
|
|
"""The keyless web-search floor: works keyless, but degraded quality."""
|
|
requires = "no key; suppressed on native-search hosts"
|
|
if env.keyless_web_allowed(config):
|
|
return BackendFinding(
|
|
name="keyless",
|
|
status=health.DEGRADED,
|
|
detail="keyless search floor (no paid key; lower quality)",
|
|
requires=requires,
|
|
)
|
|
return BackendFinding(
|
|
name="keyless",
|
|
status=health.MISSING,
|
|
detail="keyless floor suppressed: host has native web search",
|
|
prescription="",
|
|
requires=requires,
|
|
)
|
|
|
|
|
|
def _probe_reddit_public(config: Dict[str, Any]) -> BackendFinding:
|
|
"""Public keyless Reddit composite; internal lanes are sub-probe detail."""
|
|
return BackendFinding(
|
|
name="public",
|
|
status=health.OK,
|
|
detail="public keyless composite (lanes: rss, listing, arctic, shreddit)",
|
|
requires="none (public endpoints)",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry: routing declared once, from env.py's definitions where they exist.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_X_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
|
|
"xai": _key_probe("xai", "XAI_API_KEY", "XAI_API_KEY (xAI/Grok live search)"),
|
|
"grok": _probe_grok,
|
|
"bird": _probe_bird,
|
|
"xurl": _probe_xurl,
|
|
"xquik": _key_probe("xquik", "XQUIK_API_KEY", "XQUIK_API_KEY (xquik.com)"),
|
|
}
|
|
_X_PAID = {"xai", "xquik"}
|
|
# Opt-in backends: never auto-selected; require explicit pin.
|
|
_X_OPT_IN = set(env.X_BACKEND_OPT_IN)
|
|
|
|
_WEB_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
|
|
"brave": _key_probe("brave", "BRAVE_API_KEY", "BRAVE_API_KEY"),
|
|
"exa": _key_probe("exa", "EXA_API_KEY", "EXA_API_KEY"),
|
|
"serper": _key_probe("serper", "SERPER_API_KEY", "SERPER_API_KEY"),
|
|
"parallel": _key_probe("parallel", "PARALLEL_API_KEY", "PARALLEL_API_KEY"),
|
|
"keyless": _probe_web_keyless,
|
|
}
|
|
_WEB_KEYED = {"brave", "exa", "serper", "parallel"}
|
|
|
|
_SC_SPEC = BackendSpec(
|
|
name="scrapecreators",
|
|
requires="SCRAPECREATORS_API_KEY",
|
|
probe=_key_probe(
|
|
"scrapecreators", "SCRAPECREATORS_API_KEY", "SCRAPECREATORS_API_KEY",
|
|
note=_SC_PRESCRIPTION,
|
|
),
|
|
paid=True,
|
|
)
|
|
|
|
# X backend requirements, keyed by name.
|
|
_X_REQUIRES: Dict[str, str] = {
|
|
"xai": "XAI_API_KEY (xAI/Grok live search)",
|
|
"grok": "grok CLI installed + signed in (opt-in only; pin to enable)",
|
|
"bird": "X browser cookies (AUTH_TOKEN/CT0) + node",
|
|
"xurl": "xurl CLI installed + OAuth2 login",
|
|
"xquik": "XQUIK_API_KEY (xquik.com)",
|
|
}
|
|
|
|
DESCRIPTORS: Dict[str, ChainDescriptor] = {
|
|
# X: chain order and pin var imported from env.py (single source of truth).
|
|
# Backends include the auto chain (X_BACKEND_ORDER) plus opt-in entries
|
|
# (X_BACKEND_OPT_IN) for doctor visibility. Opt-in backends like grok
|
|
# appear in findings but are never auto-selected; pin to enable.
|
|
"x": ChainDescriptor(
|
|
source="x",
|
|
mode=MODE_ALTERNATIVE,
|
|
backends=tuple(
|
|
BackendSpec(
|
|
name=name,
|
|
requires=_X_REQUIRES[name],
|
|
probe=_X_PROBES[name],
|
|
paid=name in _X_PAID,
|
|
opt_in=name in _X_OPT_IN,
|
|
)
|
|
for name in env.X_BACKEND_ORDER + env.X_BACKEND_OPT_IN
|
|
),
|
|
pin_var=env.X_BACKEND_PIN_VAR,
|
|
),
|
|
"youtube": ChainDescriptor(
|
|
source="youtube",
|
|
mode=MODE_ALTERNATIVE,
|
|
backends=(
|
|
BackendSpec(
|
|
name="yt-dlp",
|
|
requires="yt-dlp on the agent-subprocess PATH",
|
|
probe=_probe_ytdlp,
|
|
),
|
|
_SC_SPEC,
|
|
),
|
|
pin_var=None, # no YouTube pin knob exists
|
|
),
|
|
"web": ChainDescriptor(
|
|
source="web",
|
|
mode=MODE_ALTERNATIVE,
|
|
backends=tuple(
|
|
BackendSpec(
|
|
name=name,
|
|
requires=(f"{name.upper()}_API_KEY" if name in _WEB_KEYED
|
|
else "no key; suppressed on native-search hosts"),
|
|
probe=_WEB_PROBES[name],
|
|
paid=name in _WEB_KEYED,
|
|
)
|
|
for name in WEB_BACKEND_ORDER
|
|
),
|
|
pin_var=None, # pinned per-run via --web-backend, not an env var
|
|
pin_flag="--web-backend",
|
|
),
|
|
"reddit": ChainDescriptor(
|
|
source="reddit",
|
|
mode=MODE_CONDITIONAL,
|
|
backends=(
|
|
BackendSpec(
|
|
name="public",
|
|
requires="none (public endpoints)",
|
|
probe=_probe_reddit_public,
|
|
),
|
|
_SC_SPEC,
|
|
),
|
|
pin_var=env.REDDIT_BACKEND_PIN_VAR,
|
|
),
|
|
}
|
|
|
|
|
|
def get_descriptor(source: str) -> ChainDescriptor:
|
|
"""Return the declared routing descriptor for ``source`` (KeyError if none)."""
|
|
return DESCRIPTORS[source]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve(
|
|
source: str,
|
|
config: Dict[str, Any],
|
|
pin: Optional[str] = None,
|
|
) -> BackendResolution:
|
|
"""Resolve a chained source's routing into a truthful prediction.
|
|
|
|
``pin`` is an explicit per-run pin (the ``--web-backend`` flag); it
|
|
takes precedence over the descriptor's env pin var. ``"auto"``/None
|
|
mean unpinned. Probing is side-effect-free and collect-then-pick.
|
|
|
|
Time budget: backends are probed sequentially, so a chain's budget is
|
|
ADDITIVE across its backends — each binary-backed probe is bounded by
|
|
``health.PROBE_TIMEOUT`` and paid/key lanes are dict lookups that cost
|
|
nothing, giving a worst case of roughly (binary probes in the chain) x
|
|
``health.PROBE_TIMEOUT``. Deliberately no intra-chain concurrency:
|
|
probes are memoized per process and the worst case only occurs when
|
|
multiple binaries are simultaneously hung.
|
|
"""
|
|
descriptor = get_descriptor(source)
|
|
findings = [
|
|
_run_probe(spec, config) for spec in descriptor.backends
|
|
]
|
|
if descriptor.mode == MODE_CONDITIONAL:
|
|
return _resolve_conditional(descriptor, config, findings)
|
|
return _resolve_alternative(descriptor, config, findings, pin)
|
|
|
|
|
|
def _run_probe(spec: BackendSpec, config: Dict[str, Any]) -> BackendFinding:
|
|
"""Run one probe, isolating failures so one bad probe can't blank a chain."""
|
|
try:
|
|
finding = spec.probe(config)
|
|
except Exception as exc: # a probe bug must not take the report down
|
|
finding = BackendFinding(
|
|
name=spec.name,
|
|
status=health.ERROR,
|
|
detail=f"probe failed: {type(exc).__name__}: {exc}",
|
|
requires=spec.requires,
|
|
)
|
|
if not finding.requires:
|
|
finding.requires = spec.requires
|
|
return finding
|
|
|
|
|
|
def _resolve_alternative(
|
|
descriptor: ChainDescriptor,
|
|
config: Dict[str, Any],
|
|
findings: List[BackendFinding],
|
|
pin: Optional[str],
|
|
) -> BackendResolution:
|
|
names = [spec.name for spec in descriptor.backends]
|
|
by_name = {f.name: f for f in findings}
|
|
# Track which backends are opt-in (never auto-selected).
|
|
opt_in_names = {spec.name for spec in descriptor.backends if spec.opt_in}
|
|
res = BackendResolution(
|
|
source=descriptor.source,
|
|
mode=MODE_ALTERNATIVE,
|
|
chain=list(names),
|
|
findings=findings,
|
|
)
|
|
|
|
pin_name: Optional[str] = None
|
|
if pin and pin not in ("auto", "none") and pin in by_name:
|
|
pin_name = pin
|
|
elif descriptor.pin_var:
|
|
raw = (config.get(descriptor.pin_var) or "").lower()
|
|
if raw in by_name:
|
|
pin_name = raw
|
|
|
|
if pin_name:
|
|
# A pin forces a single backend (no failover) — mirror
|
|
# env.x_backend_chain's pin semantics exactly.
|
|
res.pinned = True
|
|
res.pin = pin_name
|
|
finding = by_name[pin_name]
|
|
if finding.status == health.OK:
|
|
res.active_backend = pin_name
|
|
res.tier = TIER_OK
|
|
elif finding.status == health.DEGRADED:
|
|
res.active_backend = pin_name
|
|
res.tier = TIER_WARN
|
|
else:
|
|
res.tier = TIER_ERROR
|
|
res.prescription = finding.prescription or (
|
|
f"unpin {descriptor.pin_var or descriptor.pin_flag} or fix {pin_name}"
|
|
)
|
|
return res
|
|
|
|
# Collect-then-pick: first fully-usable wins; else best degraded; else
|
|
# error carrying the highest-priority backend's prescription.
|
|
# Opt-in backends are NEVER auto-selected; skip them entirely.
|
|
auto_findings = [f for f in findings if f.name not in opt_in_names]
|
|
for finding in auto_findings:
|
|
if finding.status == health.OK:
|
|
res.active_backend = finding.name
|
|
res.tier = TIER_OK
|
|
return res
|
|
for finding in auto_findings:
|
|
if finding.status == health.DEGRADED:
|
|
res.active_backend = finding.name
|
|
res.tier = TIER_WARN
|
|
return res
|
|
res.tier = TIER_ERROR
|
|
# Prescription comes from the first auto-chain backend, not opt-in.
|
|
res.prescription = auto_findings[0].prescription if auto_findings else ""
|
|
return res
|
|
|
|
|
|
def _reddit_sc_min_items(config: Dict[str, Any]) -> int:
|
|
"""The thinness floor, parsed exactly as the pipeline parses it
|
|
(lib/pipeline.py reddit fetch: int(... or 0), malformed -> 0)."""
|
|
try:
|
|
return int(config.get(env.REDDIT_SC_MIN_ITEMS_VAR) or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _resolve_conditional(
|
|
descriptor: ChainDescriptor,
|
|
config: Dict[str, Any],
|
|
findings: List[BackendFinding],
|
|
) -> BackendResolution:
|
|
"""Reddit: render the real per-query semantics, never a computed winner."""
|
|
res = BackendResolution(
|
|
source=descriptor.source,
|
|
mode=MODE_CONDITIONAL,
|
|
chain=[spec.name for spec in descriptor.backends],
|
|
findings=findings,
|
|
active_backend=None, # conditional mode never picks a winner
|
|
tier=TIER_OK, # the public keyless composite is always reachable
|
|
)
|
|
has_key = bool(config.get("SCRAPECREATORS_API_KEY"))
|
|
raw_pin = (config.get(descriptor.pin_var) or "").lower() if descriptor.pin_var else ""
|
|
pinned_sc = has_key and raw_pin == "scrapecreators"
|
|
floor = _reddit_sc_min_items(config)
|
|
|
|
if pinned_sc:
|
|
res.pinned = True
|
|
res.pin = "scrapecreators"
|
|
res.conditional = (
|
|
f"ScrapeCreators primary (pinned via {descriptor.pin_var}); "
|
|
"public keyless composite fallback"
|
|
)
|
|
return res
|
|
|
|
if has_key:
|
|
if floor > 0:
|
|
backfill = (
|
|
f"ScrapeCreators backfill when results fall below the "
|
|
f"{floor}-item floor"
|
|
)
|
|
else:
|
|
backfill = "ScrapeCreators backfill when the free path returns nothing"
|
|
res.conditional = f"public keyless composite (default); {backfill}"
|
|
return res
|
|
|
|
res.conditional = "public keyless composite (default); no ScrapeCreators key for backfill"
|
|
if raw_pin == "scrapecreators":
|
|
# The pipeline ignores the pin without a key; say so honestly.
|
|
res.conditional += (
|
|
f" ({descriptor.pin_var} pin ignored: SCRAPECREATORS_API_KEY not set)"
|
|
)
|
|
return res
|