"""Búsqueda web sin API key. El parser se testea contra una respuesta real guardada en tests/fixtures/: es la parte que se rompe cuando DuckDuckGo cambia el markup, y tiene que fallar acá y no en producción. Nada en esta suite sale a internet. """ from __future__ import annotations from pathlib import Path import pytest from enlace.agent.tools.search import ( BraveBackend, CadenaDeBackends, DuckDuckGoBackend, SearchError, SearchResult, SearxNGBackend, WebSearch, _unwrap_redirect, build_backend, parse_duckduckgo_html, ) from enlace.config.load import ConfigError, load_agent_config from enlace.config.schema import SearchConfig FIXTURE = Path(__file__).parent / "fixtures" / "ddg-lite-es.html" @pytest.fixture(scope="module") def html_real() -> str: return FIXTURE.read_text(encoding="utf-8") class BackendFalso: name = "falso" def __init__(self, resultados: list[SearchResult]) -> None: self.resultados = resultados self.llamadas = 0 def search(self, query: str, max_results: int) -> list[SearchResult]: self.llamadas += 1 return self.resultados[:max_results] # --- parser --------------------------------------------------------------- def test_extrae_titulo_url_y_snippet(html_real): resultados = parse_duckduckgo_html(html_real, max_results=5) assert len(resultados) == 5 primero = resultados[0] assert primero.url.startswith("https://") assert "Home Assistant" in primero.title assert len(primero.snippet) > 40 def test_limpia_las_etiquetas_de_resaltado(html_real): """DuckDuckGo envuelve los términos buscados en ; eso no debe llegar al contexto del modelo.""" resultados = parse_duckduckgo_html(html_real, max_results=10) for r in resultados: assert "" not in r.snippet and "" not in r.snippet assert "<" not in r.title def test_resuelve_entidades_html(html_real): resultados = parse_duckduckgo_html(html_real, max_results=10) texto = " ".join(r.title + r.snippet for r in resultados) assert "&" not in texto and " " not in texto and "&#" not in texto def test_respeta_el_maximo_de_resultados(html_real): assert len(parse_duckduckgo_html(html_real, max_results=3)) == 3 assert len(parse_duckduckgo_html(html_real, max_results=1)) == 1 def test_html_sin_resultados_devuelve_lista_vacia(): assert parse_duckduckgo_html("nada", 5) == [] def test_html_truncado_no_rompe(html_real): """Una respuesta cortada a la mitad tiene que degradar, no explotar.""" resultados = parse_duckduckgo_html(html_real[: len(html_real) // 2], max_results=5) assert isinstance(resultados, list) def test_faltan_snippets_pero_hay_enlaces(): html = ( "Uno" "Dos" ) resultados = parse_duckduckgo_html(html, max_results=5) assert [r.title for r in resultados] == ["Uno", "Dos"] assert all(r.snippet == "" for r in resultados) def test_descarta_enlaces_que_no_son_http(): html = "Malo" assert parse_duckduckgo_html(html, max_results=5) == [] # --- redirecciones -------------------------------------------------------- def test_desenvuelve_la_redireccion_de_duckduckgo(): """Guardar la redirección en vez del destino ensucia la memoria episódica: dos búsquedas a la misma página parecerían páginas distintas.""" envuelto = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fejemplo.com%2Fpagina&rut=abc" assert _unwrap_redirect(envuelto) == "https://ejemplo.com/pagina" def test_deja_pasar_las_urls_directas(): assert _unwrap_redirect("https://ejemplo.com/x") == "https://ejemplo.com/x" def test_completa_el_esquema_en_urls_relativas_al_protocolo(): assert _unwrap_redirect("//ejemplo.com/x") == "https://ejemplo.com/x" # --- presupuesto de contexto ---------------------------------------------- def test_el_snippet_se_recorta_al_presupuesto(): r = SearchResult(title="T", url="https://e.com", snippet="x" * 1000) assert len(r.render(max_chars=100)) < 160 def test_render_numera_los_resultados_para_poder_citarlos(): backend = BackendFalso( [SearchResult(f"T{i}", f"https://e.com/{i}", f"cuerpo {i}") for i in range(3)] ) salida = WebSearch(backend, max_results=3).render("algo") assert salida.startswith("1. ") and "\n2. " in salida and "\n3. " in salida def test_sin_resultados_lo_dice_en_vez_de_devolver_vacio(): """El modelo tiene que poder decir que no encontró nada; una cadena vacía lo empujaría a inventar.""" assert WebSearch(BackendFalso([])).render("algo") == "Sin resultados." # --- caché ---------------------------------------------------------------- def test_la_cache_evita_repetir_la_consulta_externa(): backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) tool = WebSearch(backend, cache_ttl=300.0) tool.search("misma pregunta") tool.search("misma pregunta") assert backend.llamadas == 1 def test_la_cache_no_mezcla_consultas_distintas(): backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) tool = WebSearch(backend, cache_ttl=300.0) tool.search("una") tool.search("otra") assert backend.llamadas == 2 def test_cache_ttl_cero_siempre_consulta(): backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) tool = WebSearch(backend, cache_ttl=0.0) tool.search("q") tool.search("q") assert backend.llamadas == 2 def test_consulta_vacia_da_error_util(): with pytest.raises(SearchError, match="vacía"): WebSearch(BackendFalso([])).search(" ") # --- config --------------------------------------------------------------- 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) primero = backend.backends[0] if isinstance(backend, CadenaDeBackends) else backend assert isinstance(primero, DuckDuckGoBackend) def test_searxng_exige_url(): with pytest.raises(ValueError, match="searxng_url"): SearchConfig(backend="searxng") def test_searxng_con_url_construye_su_backend(): cfg = SearchConfig(backend="searxng", searxng_url="http://localhost:8888") assert isinstance(build_backend(cfg), SearxNGBackend) def test_el_presupuesto_de_contexto_tiene_un_tope_razonable(): """max_results acotado no es un capricho: con seq_len 2048 los resultados compiten con la memoria recuperada y el turno del usuario.""" with pytest.raises(ValueError): SearchConfig(max_results=100) 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