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:
+7
-4
@@ -5,10 +5,13 @@
|
||||
ENLACE_REMOTE_HOST=usuario@servidor
|
||||
ENLACE_REMOTE_DIR=/home/usuario/ENLACE
|
||||
|
||||
# --- Búsqueda web (elegir uno; SearxNG autoalojado es la opción sin terceros) ---
|
||||
ENLACE_SEARXNG_URL=http://localhost:8888
|
||||
# ENLACE_BRAVE_API_KEY=
|
||||
# ENLACE_TAVILY_API_KEY=
|
||||
# --- Búsqueda web ---
|
||||
# Por defecto se usa DuckDuckGo, que no necesita API key ni cuenta: no hay nada
|
||||
# que configurar acá. Ver configs/agent/tools.yaml.
|
||||
#
|
||||
# Solo si algún día se cambia a un SearxNG autoalojado (search.backend=searxng),
|
||||
# hay que apuntar esta variable a su URL.
|
||||
# ENLACE_SEARXNG_URL=http://localhost:8888
|
||||
|
||||
# --- Home Assistant ---
|
||||
# ENLACE_HA_URL=http://homeassistant.local:8123
|
||||
|
||||
@@ -15,7 +15,16 @@ Etapa 0 (entorno y validación del stack) implementada y verificada:
|
||||
- Arquitectura del modelo: RMSNorm, SwiGLU, RoPE, GQA, QK-norm, z-loss.
|
||||
- Entrenador con WSD, precisión mixta, acumulación de gradiente y **reanudación exacta**.
|
||||
- Cargadores de datos: bytes (smoke test) y shards `uint16` (corpus real).
|
||||
- 48 tests, incluido el de reanudación exacta.
|
||||
|
||||
Primera tool del agente (Etapa 4), que no depende del modelo y ya funciona:
|
||||
|
||||
- Búsqueda web con DuckDuckGo, **sin API key ni cuenta en ningún servicio**.
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m enlace.agent.tools.search "que es home assistant"
|
||||
```
|
||||
|
||||
71 tests, incluido el de reanudación exacta.
|
||||
|
||||
Pendiente: Etapa 1 en adelante (corpus, tokenizer, pretraining, post-training,
|
||||
agente, bases del sistema, memoria, snapshots).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Herramientas del agente.
|
||||
#
|
||||
# La búsqueda web usa DuckDuckGo, que no exige API key ni cuenta en ningún
|
||||
# servicio: no hay credenciales que gestionar ni un tercero al que se le informe
|
||||
# qué busca la familia.
|
||||
#
|
||||
# Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer")
|
||||
# solo devuelve definiciones y fichas de entidades, no resultados web. Los
|
||||
# resultados reales salen del endpoint lite, que no es una API con contrato: el
|
||||
# HTML puede cambiar y hay límite de tasa. Por eso existe el backend `searxng`,
|
||||
# autoalojado y estable, como reemplazo de una línea cuando haga falta.
|
||||
search:
|
||||
backend: duckduckgo
|
||||
region: es-es
|
||||
language: es
|
||||
|
||||
# Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados
|
||||
# de búsqueda, memoria recuperada y el turno del usuario: cinco resultados
|
||||
# recortados le sirven más al modelo que diez completos.
|
||||
max_results: 5
|
||||
snippet_chars: 240
|
||||
|
||||
timeout: 10.0
|
||||
retries: 2
|
||||
|
||||
# En una casa las mismas preguntas se repiten muchas veces por día. La caché
|
||||
# es de comportamiento, no de rendimiento: evita repetir consultas externas
|
||||
# idénticas en pocos minutos.
|
||||
cache_ttl: 300.0
|
||||
|
||||
# Solo se usa con backend=searxng. Sale de .env y queda vacío si no está.
|
||||
searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""}
|
||||
+2
-1
@@ -262,7 +262,8 @@ Con tope de pasos, timeouts y fallback explícito a "no entendí" — preferible
|
||||
- **Canales** (`enlace/channels/`): cada front-end es un adaptador que aporta la identidad fuerte y
|
||||
normaliza la entrada. CLI primero; después HTTP API, bot de mensajería, agente conversacional de Home
|
||||
Assistant, y voz. Agregar un canal no toca el runtime.
|
||||
- **Tools** (`enlace/agent/tools/`), en orden: **búsqueda web** (SearxNG autoalojado, o Brave/Tavily) →
|
||||
- **Tools** (`enlace/agent/tools/`), en orden: **búsqueda web** (DuckDuckGo, sin API key ni cuenta;
|
||||
SearxNG autoalojado como reemplazo si el endpoint falla) →
|
||||
Home Assistant (API REST, con sincronización a `devices.db`) → calendario (CalDAV) → dispositivos
|
||||
adicionales. Cada tool: esquema JSON + permiso + fuente de enums + implementación + tests.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Runtime del agente: independiente del modelo detrás de una interfaz.
|
||||
|
||||
Se desarrolla y se prueba antes de que el modelo propio esté entrenado, y
|
||||
después se cambia el backend de generación por config.
|
||||
"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Catálogo de herramientas.
|
||||
|
||||
Cada tool declara su esquema, el permiso que requiere y de qué base saca sus
|
||||
valores enumerados. Agregar un dispositivo es agregar filas a una base; agregar
|
||||
una clase de dispositivo es agregar un módulo acá.
|
||||
|
||||
Los módulos se importan directamente (`from enlace.agent.tools.search import
|
||||
WebSearch`) y no se reexportan acá: varios son ejecutables con `python -m`, y
|
||||
reexportarlos hace que se importen dos veces.
|
||||
"""
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Búsqueda web. Sin API key y sin cuenta en ningún servicio.
|
||||
|
||||
Se usa DuckDuckGo por su endpoint ligero, que devuelve resultados web sin
|
||||
credenciales. Dos aclaraciones sobre por qué está hecho así:
|
||||
|
||||
- La API oficial de DuckDuckGo (api.duckduckgo.com, "Instant Answer") no
|
||||
sirve acá: 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/html.
|
||||
- Ese endpoint 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 hay una
|
||||
implementación alternativa contra SearxNG autoalojado, que sí es estable y
|
||||
no depende de terceros. Cambiar de una a otra es una línea de config.
|
||||
|
||||
El resultado se normaliza y se recorta con un presupuesto de caracteres: con un
|
||||
modelo chico y 2048 tokens de contexto, volcarle diez resultados completos es
|
||||
peor que darle tres buenos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
# Un navegador real: el endpoint lite rechaza clientes sin User-Agent.
|
||||
_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36"
|
||||
|
||||
_RE_LINK = re.compile(
|
||||
r"<a[^>]*?href=[\"'](?P<url>[^\"']+)[\"'][^>]*?class=['\"]result-link['\"][^>]*?>"
|
||||
r"(?P<title>.*?)</a>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
_RE_SNIPPET = re.compile(
|
||||
r"class=['\"]result-snippet['\"][^>]*>(?P<snippet>.*?)</td>", re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
_RE_TAGS = re.compile(r"<[^>]+>")
|
||||
_RE_SPACES = re.compile(r"\s+")
|
||||
|
||||
|
||||
class SearchError(RuntimeError):
|
||||
"""La búsqueda no se pudo completar. El agente responde que no pudo buscar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchResult:
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
|
||||
def render(self, max_chars: int) -> str:
|
||||
"""Una línea compacta para inyectar en el contexto del modelo."""
|
||||
cuerpo = self.snippet[:max_chars].rstrip()
|
||||
return f"{self.title} — {cuerpo} ({self.url})"
|
||||
|
||||
|
||||
def _clean(raw: str) -> str:
|
||||
"""Quita etiquetas, resuelve entidades y colapsa espacios."""
|
||||
return _RE_SPACES.sub(" ", html.unescape(_RE_TAGS.sub("", raw))).strip()
|
||||
|
||||
|
||||
def _unwrap_redirect(url: str) -> str:
|
||||
"""DuckDuckGo a veces envuelve el destino en /l/?uddg=<url>.
|
||||
|
||||
Guardar la redirección en vez del destino ensucia la memoria episódica y
|
||||
hace imposible reconocer que dos búsquedas llegaron a la misma página.
|
||||
"""
|
||||
if "uddg=" not in url:
|
||||
return url if url.startswith("http") else f"https:{url}" if url.startswith("//") else url
|
||||
query = urllib.parse.urlparse(url if url.startswith("http") else f"https:{url}").query
|
||||
destino = urllib.parse.parse_qs(query).get("uddg", [])
|
||||
return destino[0] if destino else url
|
||||
|
||||
|
||||
def parse_duckduckgo_html(body: str, max_results: int) -> list[SearchResult]:
|
||||
"""Extrae los resultados del HTML del endpoint lite.
|
||||
|
||||
Está separado de la red a propósito: es la parte que se rompe cuando
|
||||
DuckDuckGo cambia el markup, y se testea contra una respuesta real guardada
|
||||
en tests/fixtures/ sin salir a internet.
|
||||
"""
|
||||
enlaces = list(_RE_LINK.finditer(body))
|
||||
fragmentos = [m.group("snippet") for m in _RE_SNIPPET.finditer(body)]
|
||||
|
||||
resultados: list[SearchResult] = []
|
||||
for i, enlace in enumerate(enlaces):
|
||||
titulo = _clean(enlace.group("title"))
|
||||
url = _unwrap_redirect(html.unescape(enlace.group("url")))
|
||||
if not titulo or not url.startswith("http"):
|
||||
continue
|
||||
# Los snippets vienen en el mismo orden que los enlaces, pero puede
|
||||
# faltar alguno: se empareja por posición y se tolera la ausencia.
|
||||
snippet = _clean(fragmentos[i]) if i < len(fragmentos) else ""
|
||||
resultados.append(SearchResult(title=titulo, url=url, snippet=snippet))
|
||||
if len(resultados) >= max_results:
|
||||
break
|
||||
return resultados
|
||||
|
||||
|
||||
class SearchBackend(Protocol):
|
||||
def search(self, query: str, max_results: int) -> list[SearchResult]: ...
|
||||
|
||||
|
||||
class DuckDuckGoBackend:
|
||||
"""Endpoint lite de DuckDuckGo. Sin clave, sin cuenta, sin terceros."""
|
||||
|
||||
name = "duckduckgo"
|
||||
ENDPOINT = "https://lite.duckduckgo.com/lite/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
region: str = "es-es",
|
||||
timeout: float = 10.0,
|
||||
retries: int = 2,
|
||||
backoff: float = 1.5,
|
||||
) -> None:
|
||||
self.region = region
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.backoff = backoff
|
||||
|
||||
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||
datos = urllib.parse.urlencode({"q": query, "kl": self.region}).encode()
|
||||
request = urllib.request.Request(
|
||||
self.ENDPOINT, data=datos, headers={"User-Agent": _USER_AGENT}
|
||||
)
|
||||
|
||||
ultimo: Exception | None = None
|
||||
for intento in range(self.retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as respuesta:
|
||||
body = respuesta.read().decode("utf-8", errors="replace")
|
||||
return parse_duckduckgo_html(body, max_results)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
ultimo = exc
|
||||
if intento < self.retries:
|
||||
# Espera creciente: el endpoint limita por tasa y reintentar
|
||||
# de inmediato solo empeora el bloqueo.
|
||||
time.sleep(self.backoff * (intento + 1))
|
||||
|
||||
raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}")
|
||||
|
||||
|
||||
class SearxNGBackend:
|
||||
"""SearxNG autoalojado: la opción estable y sin depender de terceros.
|
||||
|
||||
Es el destino natural cuando el endpoint lite empiece a fallar o cuando el
|
||||
volumen de consultas de la casa lo justifique. SearxNG puede agregar
|
||||
DuckDuckGo entre sus fuentes, así que el resultado es equivalente.
|
||||
"""
|
||||
|
||||
name = "searxng"
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 10.0, language: str = "es") -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.language = language
|
||||
|
||||
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||
url = f"{self.base_url}/search?" + urllib.parse.urlencode(
|
||||
{"q": query, "format": "json", "language": self.language}
|
||||
)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as respuesta:
|
||||
payload = json.loads(respuesta.read())
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
raise SearchError(f"SearxNG en {self.base_url} no respondió: {exc}") from None
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
title=_clean(item.get("title", "")),
|
||||
url=item.get("url", ""),
|
||||
snippet=_clean(item.get("content", "")),
|
||||
)
|
||||
for item in payload.get("results", [])[:max_results]
|
||||
]
|
||||
|
||||
|
||||
class WebSearch:
|
||||
"""Tool de búsqueda: backend + presupuesto de contexto + caché.
|
||||
|
||||
La caché no es una optimización de rendimiento sino de comportamiento: en
|
||||
una casa las mismas preguntas se repiten muchas veces por día, y repetir la
|
||||
consulta externa no aporta nada. La versión persistente vive después en la
|
||||
memoria episódica; esta es en memoria y por proceso.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: SearchBackend,
|
||||
max_results: int = 5,
|
||||
snippet_chars: int = 240,
|
||||
cache_ttl: float = 300.0,
|
||||
) -> None:
|
||||
self.backend = backend
|
||||
self.max_results = max_results
|
||||
self.snippet_chars = snippet_chars
|
||||
self.cache_ttl = cache_ttl
|
||||
self._cache: dict[str, tuple[float, list[SearchResult]]] = {}
|
||||
|
||||
def search(self, query: str) -> list[SearchResult]:
|
||||
query = query.strip()
|
||||
if not query:
|
||||
raise SearchError("la consulta está vacía")
|
||||
|
||||
ahora = time.monotonic()
|
||||
entrada = self._cache.get(query)
|
||||
if entrada is not None and ahora - entrada[0] < self.cache_ttl:
|
||||
return entrada[1]
|
||||
|
||||
resultados = self.backend.search(query, self.max_results)
|
||||
self._cache[query] = (ahora, resultados)
|
||||
return resultados
|
||||
|
||||
def render(self, query: str) -> str:
|
||||
"""Bloque de texto listo para inyectar como resultado de la tool.
|
||||
|
||||
Se numera y se recorta: el modelo tiene que poder citar "el segundo
|
||||
resultado" y el bloque entero tiene que entrar en el presupuesto de
|
||||
contexto sin desplazar la memoria ni el turno del usuario.
|
||||
"""
|
||||
resultados = self.search(query)
|
||||
if not resultados:
|
||||
return "Sin resultados."
|
||||
return "\n".join(
|
||||
f"{i}. {r.render(self.snippet_chars)}" for i, r in enumerate(resultados, 1)
|
||||
)
|
||||
|
||||
|
||||
def build_backend(config) -> SearchBackend:
|
||||
"""Construye el backend declarado en la config del agente."""
|
||||
if config.backend == "duckduckgo":
|
||||
return DuckDuckGoBackend(
|
||||
region=config.region,
|
||||
timeout=config.timeout,
|
||||
retries=config.retries,
|
||||
)
|
||||
if config.backend == "searxng":
|
||||
if not config.searxng_url:
|
||||
raise SearchError(
|
||||
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
|
||||
)
|
||||
return SearxNGBackend(
|
||||
base_url=config.searxng_url, timeout=config.timeout, language=config.language
|
||||
)
|
||||
raise SearchError(f"backend de búsqueda desconocido: {config.backend}")
|
||||
|
||||
|
||||
def build_search(config) -> WebSearch:
|
||||
"""Tool de búsqueda completa a partir de la config del agente."""
|
||||
return WebSearch(
|
||||
backend=build_backend(config),
|
||||
max_results=config.max_results,
|
||||
snippet_chars=config.snippet_chars,
|
||||
cache_ttl=config.cache_ttl,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""`python -m enlace.agent.tools.search "<consulta>"`
|
||||
|
||||
Sirve para verificar la búsqueda sin el modelo: es la única parte del agente
|
||||
que se puede probar de punta a punta antes de que exista el modelo propio.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from enlace.config.load import ConfigError, load_agent_config
|
||||
|
||||
consulta = " ".join(sys.argv[1:]).strip()
|
||||
if not consulta:
|
||||
print('uso: python -m enlace.agent.tools.search "<consulta>"', file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cfg = load_agent_config()
|
||||
tool = build_search(cfg.search)
|
||||
print(f"[{tool.backend.name}] {consulta}\n")
|
||||
print(tool.render(consulta))
|
||||
except (SearchError, ConfigError) as exc:
|
||||
print(f"[enlace] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,25 +4,30 @@ Todo hiperparámetro, ruta y umbral vive en `configs/*.yaml`, se compone por
|
||||
capas y se valida con pydantic antes de que arranque cualquier proceso largo.
|
||||
"""
|
||||
|
||||
from enlace.config.load import load_config, load_config_from_argv
|
||||
from enlace.config.load import load_agent_config, load_config, load_config_from_argv
|
||||
from enlace.config.schema import (
|
||||
AgentConfig,
|
||||
Config,
|
||||
DataConfig,
|
||||
HardwareConfig,
|
||||
ModelConfig,
|
||||
OptimizerConfig,
|
||||
ScheduleConfig,
|
||||
SearchConfig,
|
||||
TrainConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentConfig",
|
||||
"Config",
|
||||
"DataConfig",
|
||||
"HardwareConfig",
|
||||
"ModelConfig",
|
||||
"OptimizerConfig",
|
||||
"ScheduleConfig",
|
||||
"SearchConfig",
|
||||
"TrainConfig",
|
||||
"load_agent_config",
|
||||
"load_config",
|
||||
"load_config_from_argv",
|
||||
]
|
||||
|
||||
+35
-1
@@ -15,7 +15,7 @@ from typing import Any
|
||||
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from enlace.config.schema import Config
|
||||
from enlace.config.schema import AgentConfig, Config
|
||||
|
||||
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
|
||||
# fusionan. El orden solo importa para los mensajes de error.
|
||||
@@ -100,6 +100,40 @@ def load_config(path: str | Path, overrides: list[str] | None = None) -> Config:
|
||||
raise ConfigError(f"config inválida ({path}):\n{exc}") from None
|
||||
|
||||
|
||||
def load_agent_config(
|
||||
path: str | Path | None = None, overrides: list[str] | None = None
|
||||
) -> AgentConfig:
|
||||
"""Carga la config del runtime del agente.
|
||||
|
||||
Es un árbol aparte del de entrenamiento: el agente no necesita saber nada
|
||||
del optimizador ni del schedule. Los valores que salen de `.env` se resuelven
|
||||
con `${oc.env:...}`, así que los secretos y las URLs de la instalación nunca
|
||||
entran en un archivo commiteado.
|
||||
"""
|
||||
if path is None:
|
||||
path = Path(__file__).resolve().parents[2] / "configs" / "agent" / "tools.yaml"
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise ConfigError(f"no existe el archivo de config: {path}")
|
||||
|
||||
merged = _load_yaml(path)
|
||||
if overrides:
|
||||
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
|
||||
data = OmegaConf.to_container(merged, resolve=True)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
# OmegaConf devuelve cadena vacía cuando la variable de entorno no está
|
||||
# definida; para pydantic eso tiene que ser ausencia, no un valor vacío.
|
||||
search = data.get("search")
|
||||
if isinstance(search, dict) and search.get("searxng_url") == "":
|
||||
search["searxng_url"] = None
|
||||
|
||||
try:
|
||||
return AgentConfig.model_validate(data)
|
||||
except Exception as exc:
|
||||
raise ConfigError(f"config de agente inválida ({path}):\n{exc}") from None
|
||||
|
||||
|
||||
def load_config_from_argv(argv: list[str] | None = None) -> Config:
|
||||
"""`prog config.yaml [clave.sub=valor ...]`, para los entrypoints."""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
|
||||
@@ -201,6 +201,47 @@ class TrainConfig(_Base):
|
||||
return self
|
||||
|
||||
|
||||
class SearchConfig(_Base):
|
||||
"""Búsqueda web. Sin API key: DuckDuckGo no exige credenciales.
|
||||
|
||||
`searxng` existe como alternativa autoalojada para cuando el endpoint de
|
||||
DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend
|
||||
es cambiar este campo.
|
||||
"""
|
||||
|
||||
backend: Literal["duckduckgo", "searxng"] = "duckduckgo"
|
||||
region: str = "es-es"
|
||||
language: str = "es"
|
||||
max_results: int = Field(default=5, gt=0, le=20)
|
||||
# Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados
|
||||
# de búsqueda, memoria recuperada y el turno del usuario. Diez resultados
|
||||
# completos desplazan todo lo demás.
|
||||
snippet_chars: int = Field(default=240, gt=0)
|
||||
timeout: float = Field(default=10.0, gt=0)
|
||||
retries: int = Field(default=2, ge=0)
|
||||
cache_ttl: float = Field(default=300.0, ge=0)
|
||||
searxng_url: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_backend(self) -> SearchConfig:
|
||||
if self.backend == "searxng" and not self.searxng_url:
|
||||
raise ValueError(
|
||||
"search.backend='searxng' exige searxng_url "
|
||||
"(definí ENLACE_SEARXNG_URL en .env)."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AgentConfig(_Base):
|
||||
"""Config del runtime del agente, independiente del entrenamiento.
|
||||
|
||||
El agente corre contra el modelo entrenado pero no necesita nada de la
|
||||
config de entrenamiento: se cargan por separado.
|
||||
"""
|
||||
|
||||
search: SearchConfig = SearchConfig()
|
||||
|
||||
|
||||
class Config(_Base):
|
||||
"""Config raíz: la composición de todas las capas."""
|
||||
|
||||
|
||||
Vendored
+723
@@ -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'> </p>
|
||||
<div class="header">
|
||||
DuckDuckGo
|
||||
</div>
|
||||
<p class='extra'> </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"> </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 >">
|
||||
<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">
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="es-es" type="hidden">
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
<p class='extra'> </p>
|
||||
|
||||
<table border="0">
|
||||
|
||||
</table>
|
||||
|
||||
<table border="0">
|
||||
|
||||
|
||||
<!-- Web results are present -->
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
1.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.redeszone.net/tutoriales/domotica/home-assistant-que-es-tipos-instalacion/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
2.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>ardumania.es/que-es-home-assistant/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
3.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>es.wikipedia.org/wiki/Home_Assistant</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
4.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>blogdedomotica.com/home-assistant-que-es-y-para-que-sirve/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
5.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.home-assistant.io</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
6.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.domoticaeconomica.com/que-es-home-assistant-guia-principiantes/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
7.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>programarfacil.com/domotica/guia-domotica-home-assistant/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
8.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.2smarthome.es/blog/home-assistant/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
9.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>seguridadpy.info/2023/10/home-assistant-que-es-tipos-instalacion/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
10.
|
||||
|
||||
</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> </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> </td>
|
||||
<td>
|
||||
<span class='link-text'>tecnoyfoto.com/home-assistant-que-es</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </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 >">
|
||||
<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">
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="es-es" type="hidden">
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<p class='extra'> </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'> </p>
|
||||
|
||||
|
||||
<img src="//duckduckgo.com/t/sl_l"/>
|
||||
|
||||
<div id='end-spacer'> </div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 "&" 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_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")
|
||||
Reference in New Issue
Block a user