diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8a692c7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(*)" + ] + } +} \ No newline at end of file diff --git a/enlace/agent/tools/search.py b/enlace/agent/tools/search.py index f99560b..936b1ae 100644 --- a/enlace/agent/tools/search.py +++ b/enlace/agent/tools/search.py @@ -26,6 +26,7 @@ import time import urllib.error import urllib.parse import urllib.request +import warnings from dataclasses import dataclass from typing import Protocol @@ -374,7 +375,18 @@ def build_backend(config) -> SearchBackend: Con un solo backend devuelve ese backend pelado, para no envolver en una cadena algo que no la necesita. + + Los respaldos sin credencial ya vienen filtrados de la config, pero se avisa + acá: quedarse sin red de seguridad no puede pasar en silencio, y este es el + punto por el que pasa cualquier entrypoint que use búsqueda. """ + for nombre, variable in config.respaldos_omitidos: + warnings.warn( + f"búsqueda: el respaldo '{nombre}' queda deshabilitado porque falta " + f"{variable}. Si el primario '{config.backend}' falla, no hay red.", + RuntimeWarning, + stacklevel=2, + ) cadena = [_construir_uno(nombre, config) for nombre in config.cadena] return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena) diff --git a/enlace/config/schema.py b/enlace/config/schema.py index 07d160a..6debd18 100644 --- a/enlace/config/schema.py +++ b/enlace/config/schema.py @@ -250,33 +250,63 @@ class SearchConfig(_Base): searxng_url: str | None = None brave_api_key: str | None = None + # Qué credencial necesita cada backend, y de qué variable de entorno sale. + _CREDENCIALES = { + "searxng": ("searxng_url", "ENLACE_SEARXNG_URL"), + "brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"), + } + + def _falta_credencial(self, nombre: str) -> str | None: + requisito = self._CREDENCIALES.get(nombre) + if requisito and not getattr(self, requisito[0]): + return requisito[1] + return None + @property def cadena(self) -> list[str]: - """El primario seguido de los respaldos, sin repetidos.""" - orden = [self.backend, *self.fallbacks] + """El primario seguido de los respaldos utilizables, sin repetidos. + + Un respaldo sin su credencial se omite en vez de romper la carga. La + razón es concreta: la config vive en el repositorio y las credenciales + no, así que exigirlas para poder *leerla* dejaba el proyecto sin poder + clonarse — el CI y cualquier máquina nueva fallaban antes de empezar. + + Omitir no es esconder: `respaldos_omitidos` los expone y `build_backend` + avisa. El primario es otra cosa y sigue siendo error fatal (ver abajo): + sin él no hay búsqueda posible. + """ vistos: list[str] = [] - for nombre in orden: - if nombre not in vistos: - vistos.append(nombre) + for nombre in [self.backend, *self.fallbacks]: + if nombre in vistos: + continue + if nombre != self.backend and self._falta_credencial(nombre): + continue + vistos.append(nombre) return vistos + @property + def respaldos_omitidos(self) -> list[tuple[str, str]]: + """Respaldos descartados por falta de credencial, con qué variable falta.""" + omitidos = [] + for nombre in self.fallbacks: + if nombre == self.backend: + continue + variable = self._falta_credencial(nombre) + if variable: + omitidos.append((nombre, variable)) + return omitidos + @model_validator(mode="after") - 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)." - ) + def _check_backend_primario(self) -> SearchConfig: + # El primario sí es fatal: sin él no queda ninguna búsqueda en pie, y + # descubrirlo en la primera consulta real es tarde. + variable = self._falta_credencial(self.backend) + if variable: + campo = self._CREDENCIALES[self.backend][0] + raise ValueError( + f"search: el backend primario '{self.backend}' exige {campo} " + f"(definí {variable} en .env)." + ) return self diff --git a/tests/test_search.py b/tests/test_search.py index 4d336b8..7edbcb3 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -193,7 +193,7 @@ def test_el_primario_no_necesita_credenciales(): assert isinstance(primero, DuckDuckGoBackend) -def test_searxng_exige_url(): +def test_searxng_como_primario_exige_url(): with pytest.raises(ValueError, match="searxng_url"): SearchConfig(backend="searxng") @@ -307,22 +307,53 @@ def test_el_fallback_repetido_no_se_duplica(): 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_un_respaldo_sin_credencial_se_omite_en_vez_de_romper(): + """La config vive en el repositorio y las credenciales no. + + Exigir la clave para poder *leer* la config dejaba el proyecto sin poder + clonarse: el CI y cualquier máquina nueva fallaban antes de empezar. El + respaldo se cae de la cadena; el primario sigue funcionando. + """ + cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"]) + assert cfg.cadena == ["duckduckgo"] -def test_brave_como_primario_tambien_exige_credencial(): - with pytest.raises(ValueError, match="backend 'brave'"): +def test_el_respaldo_omitido_queda_a_la_vista(): + """Omitir no es esconder: quedarse sin red de seguridad tiene que verse.""" + cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"]) + assert cfg.respaldos_omitidos == [("brave", "ENLACE_BRAVE_API_KEY")] + + +def test_construir_la_cadena_avisa_del_respaldo_faltante(): + cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"]) + with pytest.warns(RuntimeWarning, match="ENLACE_BRAVE_API_KEY"): + build_backend(cfg) + + +def test_con_credencial_el_respaldo_si_entra(): + cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x") + assert cfg.cadena == ["duckduckgo", "brave"] + assert cfg.respaldos_omitidos == [] + + +def test_el_primario_sin_credencial_si_es_fatal(): + """Sin primario no queda ninguna búsqueda en pie: eso no se degrada.""" + with pytest.raises(ValueError, match="primario 'brave'"): SearchConfig(backend="brave") -def test_la_config_del_repo_declara_brave_de_respaldo(): +def test_la_config_del_repo_carga_sin_credenciales(monkeypatch): + """Un clon limpio, sin .env, tiene que poder leer la config del repositorio. + + Es exactamente lo que rompió en el CI: la validación exigía la clave de + Brave para poder cargar el archivo. + """ + monkeypatch.delenv("ENLACE_BRAVE_API_KEY", raising=False) + monkeypatch.setattr("enlace.config.load.load_dotenv", lambda *a, **k: []) cfg = load_agent_config() assert cfg.search.backend == "duckduckgo" - assert "brave" in cfg.search.fallbacks + assert "brave" in cfg.search.fallbacks # la intención queda declarada + assert cfg.search.cadena == ["duckduckgo"] # pero no se usa sin credencial # --- parser de Brave -------------------------------------------------------