36040d7cc8
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 <noreply@anthropic.com>
325 lines
11 KiB
Python
325 lines
11 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 (
|
|
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 <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 "&" 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("<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_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
|