Files
enlace/tests/test_search.py
T
msaldain 179062a5d4 Búsqueda web con DuckDuckGo, sin API key
Primera tool del agente. No depende del modelo, así que se puede usar y
verificar de punta a punta antes de que exista el modelo propio.

Sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer") no sirve
para esto — responde definiciones y fichas de entidades, y para una consulta
normal devuelve 200 con el cuerpo vacío. Los resultados web reales solo están
en el endpoint lite, que no es una API con contrato: el HTML puede cambiar y
hay límite de tasa. Por eso el backend está detrás de una interfaz y queda
implementado también SearxNG autoalojado, que es un cambio de una línea de
config cuando haga falta.

Decisiones:

- Presupuesto de contexto explícito: con seq_len 2048 los resultados compiten
  con la memoria recuperada y el turno del usuario, así que se devuelven cinco
  recortados en vez de diez completos.
- Las redirecciones /l/?uddg= se desenvuelven al destino real: guardarlas
  ensuciaría la memoria episódica, donde dos búsquedas a la misma página
  parecerían páginas distintas.
- Caché con TTL, que es de comportamiento y no de rendimiento: en una casa las
  mismas preguntas se repiten muchas veces por día.
- Sin resultados devuelve "Sin resultados." en vez de vacío, para que el modelo
  pueda decir que no encontró nada en lugar de inventar.

Se quitan Brave y Tavily de .env.example: ya no hay credenciales que gestionar
ni un tercero al que informarle qué busca la familia.

23 tests nuevos (71 en total), contra una respuesta real guardada como fixture:
el parser es lo que se rompe cuando cambia el markup, y tiene que fallar en la
suite y no en producción. Ningún test sale a internet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:15:13 -03:00

207 lines
6.7 KiB
Python

"""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 <b>; eso no debe llegar al
contexto del modelo."""
resultados = parse_duckduckgo_html(html_real, max_results=10)
for r in resultados:
assert "<b>" not in r.snippet and "</b>" 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 "&amp;" not in texto and "&nbsp;" 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("<html><body>nada</body></html>", 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 = (
"<a href='https://ejemplo.com/uno' class='result-link'>Uno</a>"
"<a href='https://ejemplo.com/dos' class='result-link'>Dos</a>"
)
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 = "<a href='javascript:void(0)' class='result-link'>Malo</a>"
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")