"""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 ( 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_la_config_por_defecto_no_necesita_credenciales(): cfg = load_agent_config() assert cfg.search.backend == "duckduckgo" assert cfg.search.region == "es-es" backend = build_backend(cfg.search) assert isinstance(backend, 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")