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>
This commit is contained in:
2026-07-27 23:15:13 -03:00
parent 90ac2a6582
commit 179062a5d4
12 changed files with 1369 additions and 8 deletions
+723
View File
@@ -0,0 +1,723 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1;">
<meta name="referrer" content="origin">
<meta name="HandheldFriendly" content="true" />
<meta name="robots" content="noindex, nofollow" />
<title>que es home assistant at DuckDuckGo</title>
<link title="DuckDuckGo (Lite)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_lite_v2.xml">
<link href="//duckduckgo.com/favicon.ico" rel="shortcut icon" />
<link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon" />
<link id="icon60" rel="apple-touch-icon" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_60x60.png?v=2"/>
<link id="icon76" rel="apple-touch-icon" sizes="76x76" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_76x76.png?v=2"/>
<link id="icon120" rel="apple-touch-icon" sizes="120x120" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_120x120.png?v=2"/>
<link id="icon152" rel="apple-touch-icon" sizes="152x152" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_152x152.png?v=2"/>
<link rel="image_src" href="//duckduckgo.com/assets/icons/meta/DDG-icon_256x256.png">
<link rel="stylesheet" media="handheld, all" href="//duckduckgo.com/dist/lr.48ddfe4eadf6a534e93f.css" type="text/css"/>
</head>
<body>
<p class='extra'>&nbsp;</p>
<div class="header">
DuckDuckGo
</div>
<p class='extra'>&nbsp;</p>
<form action="/lite/" method="post">
<input class='query' type="text" size="40" name="q" value="que es home assistant">
<input class='submit' type="submit" value="Search">
<div class="filters">
<select class="submit" name="kl">
<option value="" >All Regions</option>
<option value="ar-es" >Argentina</option>
<option value="au-en" >Australia</option>
<option value="at-de" >Austria</option>
<option value="be-fr" >Belgium (fr)</option>
<option value="be-nl" >Belgium (nl)</option>
<option value="br-pt" >Brazil</option>
<option value="bg-bg" >Bulgaria</option>
<option value="ca-en" >Canada (en)</option>
<option value="ca-fr" >Canada (fr)</option>
<option value="ct-ca" >Catalonia</option>
<option value="cl-es" >Chile</option>
<option value="cn-zh" >China</option>
<option value="co-es" >Colombia</option>
<option value="hr-hr" >Croatia</option>
<option value="cz-cs" >Czech Republic</option>
<option value="dk-da" >Denmark</option>
<option value="ee-et" >Estonia</option>
<option value="fi-fi" >Finland</option>
<option value="fr-fr" >France</option>
<option value="de-de" >Germany</option>
<option value="gr-el" >Greece</option>
<option value="hk-tzh" >Hong Kong</option>
<option value="hu-hu" >Hungary</option>
<option value="is-is" >Iceland</option>
<option value="in-en" >India (en)</option>
<option value="id-en" >Indonesia (en)</option>
<option value="ie-en" >Ireland</option>
<option value="il-en" >Israel (en)</option>
<option value="it-it" >Italy</option>
<option value="jp-jp" >Japan</option>
<option value="kr-kr" >Korea</option>
<option value="lv-lv" >Latvia</option>
<option value="lt-lt" >Lithuania</option>
<option value="my-en" >Malaysia (en)</option>
<option value="mx-es" >Mexico</option>
<option value="nl-nl" >Netherlands</option>
<option value="nz-en" >New Zealand</option>
<option value="no-no" >Norway</option>
<option value="pk-en" >Pakistan (en)</option>
<option value="pe-es" >Peru</option>
<option value="ph-en" >Philippines (en)</option>
<option value="pl-pl" >Poland</option>
<option value="pt-pt" >Portugal</option>
<option value="ro-ro" >Romania</option>
<option value="ru-ru" >Russia</option>
<option value="xa-ar" >Saudi Arabia</option>
<option value="sg-en" >Singapore</option>
<option value="sk-sk" >Slovakia</option>
<option value="sl-sl" >Slovenia</option>
<option value="za-en" >South Africa</option>
<option value="es-ca" >Spain (ca)</option>
<option value="es-es" selected>Spain (es)</option>
<option value="se-sv" >Sweden</option>
<option value="ch-de" >Switzerland (de)</option>
<option value="ch-fr" >Switzerland (fr)</option>
<option value="tw-tzh" >Taiwan</option>
<option value="th-en" >Thailand (en)</option>
<option value="tr-tr" >Turkey</option>
<option value="us-en" >US (English)</option>
<option value="us-es" >US (Spanish)</option>
<option value="ua-uk" >Ukraine</option>
<option value="uk-en" >United Kingdom</option>
<option value="vn-en" >Vietnam (en)</option>
</select>
<select class="submit" name="df">
<option value="" selected>Any Time</option>
<option value="d" >Past Day</option>
<option value="w" >Past Week</option>
<option value="m" >Past Month</option>
<option value="y" >Past Year</option>
</select>
</div>
</form>
<p class="extra">&nbsp;</p>
<table border="0">
<tr>
<td>
</td>
<td>
<!-- Next Page Button Sub-template -->
<form class="next_form" action="/lite/" method="post">
<input type="submit" class='navbutton' value="Next Page &gt;">
<input type="hidden" name="q" value="que es home assistant">
<input type="hidden" name="s" value="10">
<input type="hidden" name="nextParams" value="">
<input type="hidden" name="v" value="l">
<input type="hidden" name="o" value="json">
<input type="hidden" name="dc" value="11">
<input type="hidden" name="api" value="d.js">
<input type="hidden" name="vqd" value="4-256738865632874694831925890864817297944">
&nbsp;&nbsp;&nbsp;&nbsp;
<input name="kl" value="es-es" type="hidden">
</form>
</td>
</tr>
</table>
<p class='extra'>&nbsp;</p>
<table border="0">
</table>
<table border="0">
<!-- Web results are present -->
<tr>
<td valign="top">
1.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://www.redeszone.net/tutoriales/domotica/home-assistant-que-es-tipos-instalacion/" class='result-link'>Home Assistant: Qué es, funcionamiento y tipos de instalación</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> <b>es</b> un completo sistema operativo de código abierto, <b>que</b> nos permitirá integrar cientos de marcas de dispositivos domóticos, y miles de dispositivos, y todo ello de forma simultánea para tener el control total de la domótica en nuestro hogar.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.redeszone.net/tutoriales/domotica/home-assistant-que-es-tipos-instalacion/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
2.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://ardumania.es/que-es-home-assistant/" class='result-link'>Qué es Home Assistant: domótica libre y privacidad para tu hogar ...</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Mientras <b>que</b> Alexa, Google <b>Home</b> y similares destacan por la usabilidad inmediata, <b>Home</b> <b>Assistant</b> <b>es</b>, hoy por hoy, la referencia universal para avanzar en la domótica doméstica, tanto a nivel de control, integración <b>e</b> independencia, como de capacidad para automatizar cualquier rutina.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>ardumania.es/que-es-home-assistant/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
3.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://es.wikipedia.org/wiki/Home_Assistant" class='result-link'>Home Assistant - Wikipedia, la enciclopedia libre</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> <b>es</b> un software gratuito y de código abierto utilizado para la automatización del hogar (domótica). Sirve como plataforma de integración y centro de control del hogar inteligente, permitiendo a los usuarios controlar dispositivos domésticos inteligentes.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>es.wikipedia.org/wiki/Home_Assistant</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
4.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://blogdedomotica.com/home-assistant-que-es-y-para-que-sirve/" class='result-link'>Home Assistant ¿Qué es y para qué sirve? - Blog de Domotica</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> <b>es</b> un software open source ( de código abierto ) <b>que</b> se ejecuta bajo el lenguaje de programación Python. Con él podrás controlar y monitorizar todos los dispositivos inteligentes <b>que</b> tengas en tu casa.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>blogdedomotica.com/home-assistant-que-es-y-para-que-sirve/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
5.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://www.home-assistant.io/" class='result-link'>Home Assistant</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Use the official <b>Home</b> <b>Assistant</b> apps, a convenient companion to quickly control your devices and be notified when things happen in your <b>home</b>, even on your wrist using the Apple Watch.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.home-assistant.io</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
6.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://www.domoticaeconomica.com/que-es-home-assistant-guia-principiantes/" class='result-link'>Qué es Home Assistant: guía para principiantes (2026)</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Qu</b>é <b>es</b> <b>Home</b> <b>Assistant</b>: guía fácil para principiantes. Descubre sus ventajas, formas de uso y <b>qué</b> necesitas para empezar tu casa inteligente, gratis y en local.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.domoticaeconomica.com/que-es-home-assistant-guia-principiantes/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
7.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://programarfacil.com/domotica/guia-domotica-home-assistant/" class='result-link'>Guía definitiva de Home Assistant: domótica, instalación y primeras ...</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> <b>es</b> un software de domótica de código abierto (open source) desarrollado en Python, cuyo código está disponible en GitHub. Su misión <b>es</b> unificar todos tus dispositivos inteligentes y ofrecer un único panel de control local, sin depender de servidores externos.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>programarfacil.com/domotica/guia-domotica-home-assistant/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
8.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://www.2smarthome.es/blog/home-assistant/" class='result-link'>Qué es Home Assistant: guía completa en español</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> (HA) <b>es</b> un software de domótica de código abierto y gratuito, creado en 2013 por Paulus Schoutsen y mantenido hoy bajo la Open <b>Home</b> Foundation.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.2smarthome.es/blog/home-assistant/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
9.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://seguridadpy.info/2023/10/home-assistant-que-es-tipos-instalacion/" class='result-link'>Qué es Home Assistant, para qué sirve y tipos de instalación</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
<b>Home</b> <b>Assistant</b> <b>es</b> un completo sistema operativo de código abierto, <b>que</b> nos permitirá integrar cientos de marcas de dispositivos domóticos, y miles de dispositivos, y todo ello de forma simultánea para tener el control total de la domótica en nuestro hogar.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>seguridadpy.info/2023/10/home-assistant-que-es-tipos-instalacion/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
10.&nbsp;
</td>
<td>
<a rel="nofollow" href="https://tecnoyfoto.com/home-assistant-que-es" class='result-link'>Qué es Home Assistant: Tu Hogar Inteligente en 2026</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
En pleno 2026, <b>Home</b> <b>Assistant</b> se ha consolidado como mucho más <b>que</b> un simple asistente para el hogar: <b>es</b> el cerebro de la domótica local. Se trata de una plataforma de automatización residencial avanzada, de código abierto y completamente gratuita, diseñada con un pilar fundamental: tu privacidad.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>tecnoyfoto.com/home-assistant-que-es</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td colspan=2>
<!-- Next Page Button Sub-template -->
<form class="next_form" action="/lite/" method="post">
<input type="submit" class='navbutton' value="Next Page &gt;">
<input type="hidden" name="q" value="que es home assistant">
<input type="hidden" name="s" value="10">
<input type="hidden" name="nextParams" value="">
<input type="hidden" name="v" value="l">
<input type="hidden" name="o" value="json">
<input type="hidden" name="dc" value="11">
<input type="hidden" name="api" value="d.js">
<input type="hidden" name="vqd" value="4-256738865632874694831925890864817297944">
&nbsp;&nbsp;&nbsp;&nbsp;
<input name="kl" value="es-es" type="hidden">
</form>
</td>
</tr>
</table>
<p class='extra'>&nbsp;</p>
<form action="/lite/" method="post">
<input class='query' type="text" size="40" name="q" value="que es home assistant" >
<input class='submit' type="submit" value="Search">
<input name="kl" value="es-es" type="hidden">
</form>
<p class='extra'>&nbsp;</p>
<img src="//duckduckgo.com/t/sl_l"/>
<div id='end-spacer'>&nbsp;</div>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
"""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")