From 36040d7cc8fa034f0d896d595fc8f0e39254b461 Mon Sep 17 00:00:00 2001 From: Mateo Saldain Date: Tue, 28 Jul 2026 00:39:13 -0300 Subject: [PATCH] Brave como respaldo de DuckDuckGo, y arreglo de la guardia de bf16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadena de búsqueda con respaldo. DuckDuckGo sigue de primario porque no exige credenciales: en el camino habitual ningún tercero se entera de qué busca la familia. Pero su endpoint lite no es una API con contrato, así que cuando limite por tasa o cambie el HTML, Brave responde. Dos matices del encadenado: - Cero resultados NO dispara el respaldo. Si el primario respondió bien y no encontró nada, esa es la respuesta correcta; encadenar gastaría cuota y devolvería resultados peores. Solo se avanza ante un error real. - La cadena recuerda quién respondió: al depurar una respuesta rara, lo primero que hay que saber es de dónde salió. Un respaldo sin credencial falla al arrancar y no en la primera consulta — que es justo cuando el primario ya falló y el respaldo tiene que funcionar. Aparte, verificando torch en la 2060 apareció un defecto real en backends.py: torch.cuda.is_bf16_supported() usa including_emulation=True por defecto, así que devuelve True en Turing, donde bf16 no existe en silicio. La guardia no habría atrapado el perfil de la 5090 corriendo en la 2060: el entrenamiento seguía, emulado y silencioso. Medido en la placa: 4,49 ms por matmul en bf16 contra 1,75 ms en fp16, unas 2,6x más lento. Ahora se consulta con including_emulation=False, con respaldo por compute capability. 11 tests nuevos (113 en total). Co-Authored-By: Claude Opus 5 --- .vscode/settings.json | 3 + configs/agent/tools.yaml | 26 +++++-- enlace/agent/tools/search.py | 145 ++++++++++++++++++++++++++++++++--- enlace/config/load.py | 7 ++ enlace/config/schema.py | 51 +++++++++--- enlace/train/backends.py | 27 ++++++- scripts/remote.sh | 95 +++++++++++++++++++++-- tests/test_search.py | 122 ++++++++++++++++++++++++++++- 8 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f053570 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "ROS2.distro": "jazzy" +} \ No newline at end of file diff --git a/configs/agent/tools.yaml b/configs/agent/tools.yaml index ac0960a..feb9a88 100644 --- a/configs/agent/tools.yaml +++ b/configs/agent/tools.yaml @@ -1,17 +1,28 @@ # Herramientas del agente. # -# La búsqueda web usa DuckDuckGo, que no exige API key ni cuenta en ningún -# servicio: no hay credenciales que gestionar ni un tercero al que se le informe -# qué busca la familia. +# La búsqueda web usa DuckDuckGo como primario: no exige API key ni cuenta, así +# que no hay credenciales que gestionar ni un tercero al que se le informe qué +# busca la familia. # # Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer") # solo devuelve definiciones y fichas de entidades, no resultados web. Los # resultados reales salen del endpoint lite, que no es una API con contrato: el -# HTML puede cambiar y hay límite de tasa. Por eso existe el backend `searxng`, -# autoalojado y estable, como reemplazo de una línea cuando haga falta. +# HTML puede cambiar y hay límite de tasa. Por eso existe la cadena de respaldo. search: backend: duckduckgo - region: es-es + + # Se prueban en orden, y solo ante un error real del anterior. Cero + # resultados no dispara el respaldo: si el primario respondió bien y no + # encontró nada, esa es la respuesta correcta. + # + # Brave sí es una API con contrato y límites claros, pero cada consulta pasa + # por un tercero asociado a una cuenta — por eso va de respaldo y no de + # primario. SearxNG autoalojado es el destino natural cuando el volumen lo + # justifique: agrega DuckDuckGo entre sus fuentes sin exponer las consultas. + fallbacks: [brave] + + region: es-es # DuckDuckGo + country: uy # Brave language: es # Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados @@ -28,5 +39,6 @@ search: # idénticas en pocos minutos. cache_ttl: 300.0 - # Solo se usa con backend=searxng. Sale de .env y queda vacío si no está. + # Credenciales, desde .env. Quedan vacías si la variable no está definida. + brave_api_key: ${oc.env:ENLACE_BRAVE_API_KEY,""} searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""} diff --git a/enlace/agent/tools/search.py b/enlace/agent/tools/search.py index 39fda79..cf59796 100644 --- a/enlace/agent/tools/search.py +++ b/enlace/agent/tools/search.py @@ -147,12 +147,72 @@ class DuckDuckGoBackend: raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}") +class BraveBackend: + """Brave Search API. Una API con contrato, a diferencia del endpoint lite. + + Existe como red de seguridad del primario: cuando DuckDuckGo limite por tasa + o cambie el HTML, esto responde. La contrapartida es que cada consulta pasa + por un tercero asociado a una cuenta, así que no se usa como primario salvo + que se elija explícitamente. + """ + + name = "brave" + ENDPOINT = "https://api.search.brave.com/res/v1/web/search" + + def __init__( + self, api_key: str, timeout: float = 10.0, country: str = "uy", language: str = "es" + ) -> None: + self.api_key = api_key + self.timeout = timeout + self.country = country + self.language = language + + def search(self, query: str, max_results: int) -> list[SearchResult]: + url = self.ENDPOINT + "?" + urllib.parse.urlencode( + { + "q": query, + "count": max_results, + "country": self.country, + "search_lang": self.language, + } + ) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "X-Subscription-Token": self.api_key, + "User-Agent": _USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as respuesta: + payload = json.loads(respuesta.read()) + except urllib.error.HTTPError as exc: + # 429 es el caso interesante: si el fallback también está limitado, + # el mensaje tiene que decirlo en vez de parecer un fallo genérico. + detalle = "límite de tasa alcanzado" if exc.code == 429 else f"HTTP {exc.code}" + raise SearchError(f"Brave rechazó la consulta: {detalle}") from None + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + raise SearchError(f"Brave no respondió: {exc}") from None + + resultados = payload.get("web", {}).get("results", []) + return [ + SearchResult( + title=_clean(item.get("title", "")), + url=item.get("url", ""), + snippet=_clean(item.get("description", "")), + ) + for item in resultados[:max_results] + if item.get("url", "").startswith("http") + ] + + class SearxNGBackend: """SearxNG autoalojado: la opción estable y sin depender de terceros. - Es el destino natural cuando el endpoint lite empiece a fallar o cuando el - volumen de consultas de la casa lo justifique. SearxNG puede agregar - DuckDuckGo entre sus fuentes, así que el resultado es equivalente. + Es el destino natural cuando el volumen de consultas de la casa lo + justifique. SearxNG puede agregar DuckDuckGo entre sus fuentes, así que el + resultado es equivalente sin exponer las consultas a un tercero. """ name = "searxng" @@ -183,6 +243,51 @@ class SearxNGBackend: ] +class CadenaDeBackends: + """Prueba los backends en orden y devuelve el primero que responda. + + La razón de existir: el endpoint lite de DuckDuckGo no es una API con + contrato — puede limitar por tasa o cambiar el HTML sin aviso. Con esto un + fallo deja de ser una respuesta vacía para la familia y pasa a ser un + reintento contra otro proveedor. + + Dos matices que importan: + + - **Cero resultados no es un fallo.** Si el primario responde bien y no + encontró nada, esa es la respuesta correcta; encadenar al siguiente + proveedor por eso gastaría cuota y devolvería resultados peores. + Solo se avanza ante un error real. + - **Se recuerda quién respondió** (`ultimo_backend`), porque al depurar + una respuesta rara lo primero que hay que saber es de dónde salió. + """ + + def __init__(self, backends: list[SearchBackend]) -> None: + if not backends: + raise SearchError("la cadena de búsqueda no tiene ningún backend") + self.backends = backends + self.ultimo_backend: str | None = None + + @property + def name(self) -> str: + return " -> ".join(getattr(b, "name", type(b).__name__) for b in self.backends) + + def search(self, query: str, max_results: int) -> list[SearchResult]: + fallos: list[str] = [] + for backend in self.backends: + nombre = getattr(backend, "name", type(backend).__name__) + try: + resultados = backend.search(query, max_results) + except SearchError as exc: + fallos.append(f"{nombre}: {exc}") + continue + self.ultimo_backend = nombre + return resultados + + raise SearchError( + "ningún backend de búsqueda respondió — " + " | ".join(fallos) + ) + + class WebSearch: """Tool de búsqueda: backend + presupuesto de contexto + caché. @@ -234,15 +339,23 @@ class WebSearch: ) -def build_backend(config) -> SearchBackend: - """Construye el backend declarado en la config del agente.""" - if config.backend == "duckduckgo": +def _construir_uno(nombre: str, config) -> SearchBackend: + if nombre == "duckduckgo": return DuckDuckGoBackend( - region=config.region, - timeout=config.timeout, - retries=config.retries, + region=config.region, timeout=config.timeout, retries=config.retries ) - if config.backend == "searxng": + if nombre == "brave": + if not config.brave_api_key: + raise SearchError( + "backend brave sin brave_api_key: definí ENLACE_BRAVE_API_KEY en .env" + ) + return BraveBackend( + api_key=config.brave_api_key, + timeout=config.timeout, + country=config.country, + language=config.language, + ) + if nombre == "searxng": if not config.searxng_url: raise SearchError( "backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env" @@ -250,7 +363,17 @@ def build_backend(config) -> SearchBackend: return SearxNGBackend( base_url=config.searxng_url, timeout=config.timeout, language=config.language ) - raise SearchError(f"backend de búsqueda desconocido: {config.backend}") + raise SearchError(f"backend de búsqueda desconocido: {nombre}") + + +def build_backend(config) -> SearchBackend: + """Construye la cadena declarada en la config: primario + respaldos. + + Con un solo backend devuelve ese backend pelado, para no envolver en una + cadena algo que no la necesita. + """ + cadena = [_construir_uno(nombre, config) for nombre in config.cadena] + return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena) def build_search(config) -> WebSearch: diff --git a/enlace/config/load.py b/enlace/config/load.py index 26a9c31..c3f6ba9 100644 --- a/enlace/config/load.py +++ b/enlace/config/load.py @@ -15,6 +15,8 @@ from typing import Any from omegaconf import DictConfig, OmegaConf +from enlace.config.env import load_dotenv + from enlace.config.schema import AgentConfig, Config, DistillConfig # Familias de capas que puede declarar un YAML raíz, en el orden en que se @@ -114,6 +116,11 @@ def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: s if not path.is_file(): raise ConfigError(f"no existe el archivo de config: {path}") + # `${oc.env:...}` lee os.environ al resolver, así que .env tiene que estar + # cargado antes: si no, una credencial bien escrita en el archivo se + # resuelve a vacío y el backend parece mal configurado. + load_dotenv() + merged = _load_yaml(path) if overrides: merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides))) diff --git a/enlace/config/schema.py b/enlace/config/schema.py index 71e9284..3595a43 100644 --- a/enlace/config/schema.py +++ b/enlace/config/schema.py @@ -202,15 +202,23 @@ class TrainConfig(_Base): class SearchConfig(_Base): - """Búsqueda web. Sin API key: DuckDuckGo no exige credenciales. + """Búsqueda web, con cadena de respaldo. - `searxng` existe como alternativa autoalojada para cuando el endpoint de - DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend - es cambiar este campo. + El primario es DuckDuckGo porque no exige credenciales: nada sale a un + tercero salvo la consulta misma. Pero su endpoint lite no es una API con + contrato — puede limitar por tasa o cambiar el HTML sin aviso — así que + `fallbacks` define a quién preguntarle cuando eso pase. + + El orden importa y es explícito: se prueba `backend` y después cada entrada + de `fallbacks`, en orden, hasta que alguno responda. """ - backend: Literal["duckduckgo", "searxng"] = "duckduckgo" + backend: Literal["duckduckgo", "brave", "searxng"] = "duckduckgo" + fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field( + default_factory=list + ) region: str = "es-es" + country: str = "uy" language: str = "es" max_results: int = Field(default=5, gt=0, le=20) # Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados @@ -221,14 +229,35 @@ class SearchConfig(_Base): retries: int = Field(default=2, ge=0) cache_ttl: float = Field(default=300.0, ge=0) searxng_url: str | None = None + brave_api_key: str | None = None + + @property + def cadena(self) -> list[str]: + """El primario seguido de los respaldos, sin repetidos.""" + orden = [self.backend, *self.fallbacks] + vistos: list[str] = [] + for nombre in orden: + if nombre not in vistos: + vistos.append(nombre) + return vistos @model_validator(mode="after") - def _check_backend(self) -> SearchConfig: - if self.backend == "searxng" and not self.searxng_url: - raise ValueError( - "search.backend='searxng' exige searxng_url " - "(definí ENLACE_SEARXNG_URL en .env)." - ) + def _check_backends(self) -> SearchConfig: + # Un backend sin credencial se descubriría recién en la primera consulta + # real, que es justo cuando el primario ya falló y el respaldo tiene que + # funcionar. Se valida al arrancar. + requisitos = { + "searxng": ("searxng_url", "ENLACE_SEARXNG_URL"), + "brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"), + } + for nombre in self.cadena: + requisito = requisitos.get(nombre) + if requisito and not getattr(self, requisito[0]): + rol = "backend" if nombre == self.backend else "fallback" + raise ValueError( + f"search: el {rol} '{nombre}' exige {requisito[0]} " + f"(definí {requisito[1]} en .env)." + ) return self diff --git a/enlace/train/backends.py b/enlace/train/backends.py index ec4b7ee..d9ea38b 100644 --- a/enlace/train/backends.py +++ b/enlace/train/backends.py @@ -27,6 +27,25 @@ def resolve_dtype(name: str) -> torch.dtype: return _DTYPES[name] +def _bf16_nativo() -> bool: + """¿La GPU tiene unidades bfloat16 de verdad, o PyTorch lo emula? + + Trampa verificada en hardware: `torch.cuda.is_bf16_supported()` usa + `including_emulation=True` por defecto, así que devuelve True en Turing — + donde bf16 no existe en silicio y se emula. Medido en una RTX 2060, esa + emulación es ~2,6x más lenta que float16 (4,49 vs 1,75 ms por matmul de + 2048x2048). Confiar en el default deja pasar el perfil equivocado y el + entrenamiento corre a un tercio de velocidad sin una sola advertencia. + + bfloat16 nativo llega con Ampere (sm_80). + """ + try: + return torch.cuda.is_bf16_supported(including_emulation=False) + except TypeError: + # Versiones de torch anteriores al parámetro: se decide por capability. + return torch.cuda.get_device_capability(0) >= (8, 0) + + def describe_device(cfg: HardwareConfig) -> str: if cfg.device == "cpu": return "cpu" @@ -64,12 +83,12 @@ def setup_device(cfg: HardwareConfig) -> torch.device: f"sm_{cap[0]}{cap[1]}." ) - # Turing (sm_75) no tiene unidades bfloat16: PyTorch lo emula y el - # entrenamiento se vuelve inservible en vez de fallar. Mejor fallar. - if cfg.dtype == "bfloat16" and not torch.cuda.is_bf16_supported(): + if cfg.dtype == "bfloat16" and not _bf16_nativo(): raise HardwareError( f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) " - "no soporta de forma nativa. Usá float16 con use_grad_scaler=true." + "no soporta de forma nativa: PyTorch lo emula. Medido en una RTX 2060, " + "la emulación es ~2,6x más lenta que float16. " + "Usá float16 con use_grad_scaler=true." ) # FlashAttention-2 requiere Ampere o superior. diff --git a/scripts/remote.sh b/scripts/remote.sh index a4f4b3c..ef6e74e 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -5,27 +5,97 @@ # servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas, # muestras generadas y logs. # +# Dos direcciones para la misma máquina: la de LAN se prueba primero porque es +# el caso habitual y es mucho más rápida para rsync; si no responde, se cae a la +# pública. Forzar una con ENLACE_REMOTE_FORCE=lan|wan. +# +# scripts/remote.sh check # conectividad, GPU y entorno # scripts/remote.sh sync # empuja el código al servidor # scripts/remote.sh run [args] # entrena bajo tmux # scripts/remote.sh logs [corrida] # sigue el log en vivo # scripts/remote.sh pull # trae métricas y muestras # scripts/remote.sh gpu # estado de la GPU +# scripts/remote.sh ssh [comando] # shell o comando suelto set -euo pipefail cd "$(dirname "$0")/.." [[ -f .env ]] && set -a && source .env && set +a -HOST="${ENLACE_REMOTE_HOST:?definí ENLACE_REMOTE_HOST en .env (ver .env.example)}" -DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env}" +DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env (ver .env.example)}" PY="${ENLACE_REMOTE_PYTHON:-$DIR/.venv/bin/python}" +# Timeout corto: si no estamos en la LAN, no tiene sentido esperar el TCP. +LAN_TIMEOUT="${ENLACE_LAN_TIMEOUT:-3}" +SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new) + +_alcanzable() { + ssh "${SSH_OPTS[@]}" -o "ConnectTimeout=$1" "$2" true 2>/dev/null +} + +elegir_host() { + local lan="${ENLACE_REMOTE_LAN_HOST:-}" wan="${ENLACE_REMOTE_HOST:-}" + + case "${ENLACE_REMOTE_FORCE:-}" in + lan) echo "${lan:?ENLACE_REMOTE_FORCE=lan pero ENLACE_REMOTE_LAN_HOST no está definido}"; return ;; + wan) echo "${wan:?ENLACE_REMOTE_FORCE=wan pero ENLACE_REMOTE_HOST no está definido}"; return ;; + esac + + if [[ -n "$lan" ]] && _alcanzable "$LAN_TIMEOUT" "$lan"; then + echo "$lan" + return + fi + if [[ -n "$wan" ]]; then + echo "$wan" + return + fi + echo "definí ENLACE_REMOTE_LAN_HOST o ENLACE_REMOTE_HOST en .env" >&2 + exit 1 +} + +HOST="$(elegir_host)" +# A stderr para no contaminar la salida de los comandos que se parsean. +if [[ "$HOST" == "${ENLACE_REMOTE_LAN_HOST:-}" ]]; then + echo "[enlace] servidor: $HOST (LAN)" >&2 +else + echo "[enlace] servidor: $HOST (remoto)" >&2 +fi + comando="${1:-}" shift || true case "$comando" in +check) + # Todo lo que tiene que estar bien antes de comprometer horas de cómputo o + # gigabytes de corpus. Falla ruidoso y temprano. + ssh "${SSH_OPTS[@]}" "$HOST" bash -s -- "$DIR" "$PY" <<'REMOTO' +set -u +DIR="$1"; PY="$2" +echo "host: $(hostname)" +echo "repo: $DIR $( [ -d "$DIR/.git" ] && echo '(ok)' || echo '(FALTA: cloná el repo)')" +echo "python: $( [ -x "$PY" ] && "$PY" --version 2>&1 || echo 'FALTA el venv')" +echo "tmux: $(command -v tmux >/dev/null && tmux -V || echo 'FALTA (las corridas largas lo necesitan)')" +if command -v nvidia-smi >/dev/null; then + nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader | sed 's/^/gpu: /' +else + echo "gpu: nvidia-smi no está en PATH" +fi +[ -x "$PY" ] && "$PY" - <<'PY' +try: + import torch +except ImportError: + print("torch: no instalado (corré `remote.sh sync`)"); raise SystemExit +print(f"torch: {torch.__version__} | cuda disponible: {torch.cuda.is_available()}") +if torch.cuda.is_available(): + cap = torch.cuda.get_device_capability(0) + print(f" sm_{cap[0]}{cap[1]} | bf16: {torch.cuda.is_bf16_supported()}") + print(f" perfil sugerido: {'blackwell-5090' if cap >= (12,0) else 'turing-2060'}") +PY +REMOTO + ;; + sync) git push - ssh "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ." + ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ." ;; run) @@ -35,7 +105,8 @@ run) # distintas no se pisan y `logs` sabe a cuál conectarse. sesion="enlace-$(basename "$config" .yaml)" # tmux es lo que hace que cortar el SSH no mate el entrenamiento. - ssh -t "$HOST" "cd '$DIR' && tmux new-session -d -s '$sesion' \ + ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && mkdir -p runs && \ + tmux new-session -d -s '$sesion' \ \"$PY -m enlace.train.train '$config' $* 2>&1 | tee -a 'runs/$sesion.log'\" \ && echo 'corriendo en tmux: $sesion'" ;; @@ -43,9 +114,9 @@ run) logs) sesion="${1:-}" if [[ -z "$sesion" ]]; then - ssh "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f" + ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f" else - ssh "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'" + ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'" fi ;; @@ -58,11 +129,19 @@ pull) ;; gpu) - ssh "$HOST" "nvidia-smi" + ssh "${SSH_OPTS[@]}" "$HOST" "nvidia-smi" + ;; + +ssh) + if [[ $# -eq 0 ]]; then + ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && exec \$SHELL -l" + else + ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && $*" + fi ;; *) - sed -n '2,14p' "$0" + sed -n '2,20p' "$0" exit 2 ;; esac diff --git a/tests/test_search.py b/tests/test_search.py index ff934a4..02955da 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -12,6 +12,8 @@ from pathlib import Path import pytest from enlace.agent.tools.search import ( + BraveBackend, + CadenaDeBackends, DuckDuckGoBackend, SearchError, SearchResult, @@ -176,12 +178,18 @@ def test_consulta_vacia_da_error_util(): # --- config --------------------------------------------------------------- -def test_la_config_por_defecto_no_necesita_credenciales(): +def test_el_primario_no_necesita_credenciales(): + """Lo que importa del default: la primera consulta sale sin credenciales. + + La cadena puede tener respaldos que sí las exijan, pero el camino habitual + no le informa a ningún tercero qué busca la familia. + """ cfg = load_agent_config() assert cfg.search.backend == "duckduckgo" assert cfg.search.region == "es-es" backend = build_backend(cfg.search) - assert isinstance(backend, DuckDuckGoBackend) + primero = backend.backends[0] if isinstance(backend, CadenaDeBackends) else backend + assert isinstance(primero, DuckDuckGoBackend) def test_searxng_exige_url(): @@ -204,3 +212,113 @@ def test_el_presupuesto_de_contexto_tiene_un_tope_razonable(): def test_config_de_agente_inexistente_da_error_util(tmp_path): with pytest.raises(ConfigError, match="no existe"): load_agent_config(tmp_path / "no-existe.yaml") + + +# --- cadena de respaldo ---------------------------------------------------- + + +class BackendQueFalla: + name = "roto" + + def __init__(self, mensaje: str = "caído") -> None: + self.mensaje = mensaje + self.llamadas = 0 + + def search(self, query: str, max_results: int) -> list[SearchResult]: + self.llamadas += 1 + raise SearchError(self.mensaje) + + +def test_la_cadena_usa_el_primario_cuando_anda(): + primario = BackendFalso([SearchResult("T", "https://e.com", "c")]) + respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")]) + cadena = CadenaDeBackends([primario, respaldo]) + + assert cadena.search("q", 5)[0].title == "T" + assert respaldo.llamadas == 0 # no se toca la cuota del respaldo + assert cadena.ultimo_backend == "falso" + + +def test_la_cadena_cae_al_respaldo_ante_un_error(): + roto = BackendQueFalla("límite de tasa alcanzado") + respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")]) + cadena = CadenaDeBackends([roto, respaldo]) + + assert cadena.search("q", 5)[0].title == "R" + assert roto.llamadas == 1 + + +def test_cero_resultados_NO_dispara_el_respaldo(): + """Si el primario respondió bien y no encontró nada, esa es la respuesta. + + Encadenar al siguiente proveedor por esto gastaría cuota y devolvería + resultados peores para una consulta que genuinamente no tiene respuesta. + """ + vacio = BackendFalso([]) + respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")]) + cadena = CadenaDeBackends([vacio, respaldo]) + + assert cadena.search("consulta sin resultados", 5) == [] + assert respaldo.llamadas == 0 + + +def test_si_fallan_todos_el_error_dice_por_que_cada_uno(): + cadena = CadenaDeBackends([BackendQueFalla("429"), BackendQueFalla("timeout")]) + with pytest.raises(SearchError) as exc: + cadena.search("q", 5) + assert "429" in str(exc.value) and "timeout" in str(exc.value) + + +def test_la_cadena_recuerda_quien_respondio(): + """Al depurar una respuesta rara, lo primero es saber de dónde salió.""" + respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")]) + respaldo.name = "respaldo" + cadena = CadenaDeBackends([BackendQueFalla(), respaldo]) + cadena.search("q", 5) + assert cadena.ultimo_backend == "respaldo" + + +def test_una_cadena_vacia_es_un_error(): + with pytest.raises(SearchError, match="ningún backend"): + CadenaDeBackends([]) + + +# --- construcción desde config --------------------------------------------- + + +def test_un_solo_backend_no_se_envuelve_en_cadena(): + cfg = SearchConfig(backend="duckduckgo") + assert isinstance(build_backend(cfg), DuckDuckGoBackend) + + +def test_config_con_fallback_arma_la_cadena(): + cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x") + backend = build_backend(cfg) + assert isinstance(backend, CadenaDeBackends) + assert [type(b).__name__ for b in backend.backends] == [ + "DuckDuckGoBackend", + "BraveBackend", + ] + + +def test_el_fallback_repetido_no_se_duplica(): + cfg = SearchConfig(backend="duckduckgo", fallbacks=["duckduckgo", "brave"], brave_api_key="x") + assert cfg.cadena == ["duckduckgo", "brave"] + + +def test_un_fallback_sin_credencial_falla_al_arrancar(): + """No en la primera consulta real — que es justo cuando el primario ya + falló y el respaldo tiene que funcionar.""" + with pytest.raises(ValueError, match="fallback 'brave'"): + SearchConfig(backend="duckduckgo", fallbacks=["brave"]) + + +def test_brave_como_primario_tambien_exige_credencial(): + with pytest.raises(ValueError, match="backend 'brave'"): + SearchConfig(backend="brave") + + +def test_la_config_del_repo_declara_brave_de_respaldo(): + cfg = load_agent_config() + assert cfg.search.backend == "duckduckgo" + assert "brave" in cfg.search.fallbacks