diff --git a/.env.example b/.env.example index ca5ca64..4898315 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 53d2115..f2d7213 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/configs/agent/tools.yaml b/configs/agent/tools.yaml new file mode 100644 index 0000000..ac0960a --- /dev/null +++ b/configs/agent/tools.yaml @@ -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,""} diff --git a/docs/PLAN.md b/docs/PLAN.md index f9011e3..ac415b3 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. diff --git a/enlace/agent/__init__.py b/enlace/agent/__init__.py new file mode 100644 index 0000000..8ad4aa7 --- /dev/null +++ b/enlace/agent/__init__.py @@ -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. +""" diff --git a/enlace/agent/tools/__init__.py b/enlace/agent/tools/__init__.py new file mode 100644 index 0000000..e0054da --- /dev/null +++ b/enlace/agent/tools/__init__.py @@ -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. +""" diff --git a/enlace/agent/tools/search.py b/enlace/agent/tools/search.py new file mode 100644 index 0000000..39fda79 --- /dev/null +++ b/enlace/agent/tools/search.py @@ -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"]*?href=[\"'](?P[^\"']+)[\"'][^>]*?class=['\"]result-link['\"][^>]*?>" + r"(?P.*?)</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()) diff --git a/enlace/config/__init__.py b/enlace/config/__init__.py index bc87ad4..54855e5 100644 --- a/enlace/config/__init__.py +++ b/enlace/config/__init__.py @@ -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", ] diff --git a/enlace/config/load.py b/enlace/config/load.py index 015e20a..8cdbeaa 100644 --- a/enlace/config/load.py +++ b/enlace/config/load.py @@ -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) diff --git a/enlace/config/schema.py b/enlace/config/schema.py index 9db3ab0..1f36b55 100644 --- a/enlace/config/schema.py +++ b/enlace/config/schema.py @@ -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.""" diff --git a/tests/fixtures/ddg-lite-es.html b/tests/fixtures/ddg-lite-es.html new file mode 100644 index 0000000..60d2655 --- /dev/null +++ b/tests/fixtures/ddg-lite-es.html @@ -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 + + + + + + + + + + + + +

 

+
+ DuckDuckGo +
+

 

+ +
+ + + + + + + +
+ + + +
+
+ + +

 

+ + + + + +
+ + + + +
+ + + + + + + + + +      + + + + + + + + +
+ +
+ + + + +

 

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 1.  + + + Home Assistant: Qué es, funcionamiento y tipos de instalación +
    + Home Assistant es un completo sistema operativo de código abierto, que 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. +
    + www.redeszone.net/tutoriales/domotica/home-assistant-que-es-tipos-instalacion/ + +
  
+ + 2.  + + + Qué es Home Assistant: domótica libre y privacidad para tu hogar ... +
    + Mientras que Alexa, Google Home y similares destacan por la usabilidad inmediata, Home Assistant es, hoy por hoy, la referencia universal para avanzar en la domótica doméstica, tanto a nivel de control, integración e independencia, como de capacidad para automatizar cualquier rutina. +
    + ardumania.es/que-es-home-assistant/ + +
  
+ + 3.  + + + Home Assistant - Wikipedia, la enciclopedia libre +
    + Home Assistant es 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. +
    + es.wikipedia.org/wiki/Home_Assistant + +
  
+ + 4.  + + + Home Assistant ¿Qué es y para qué sirve? - Blog de Domotica +
    + Home Assistant es un software open source ( de código abierto ) que se ejecuta bajo el lenguaje de programación Python. Con él podrás controlar y monitorizar todos los dispositivos inteligentes que tengas en tu casa. +
    + blogdedomotica.com/home-assistant-que-es-y-para-que-sirve/ + +
  
+ + 5.  + + + Home Assistant +
    + Use the official Home Assistant apps, a convenient companion to quickly control your devices and be notified when things happen in your home, even on your wrist using the Apple Watch. +
    + www.home-assistant.io + +
  
+ + 6.  + + + Qué es Home Assistant: guía para principiantes (2026) +
    + Qué es Home Assistant: guía fácil para principiantes. Descubre sus ventajas, formas de uso y qué necesitas para empezar tu casa inteligente, gratis y en local. +
    + www.domoticaeconomica.com/que-es-home-assistant-guia-principiantes/ + +
  
+ + 7.  + + + Guía definitiva de Home Assistant: domótica, instalación y primeras ... +
    + Home Assistant es un software de domótica de código abierto (open source) desarrollado en Python, cuyo código está disponible en GitHub. Su misión es unificar todos tus dispositivos inteligentes y ofrecer un único panel de control local, sin depender de servidores externos. +
    + programarfacil.com/domotica/guia-domotica-home-assistant/ + +
  
+ + 8.  + + + Qué es Home Assistant: guía completa en español +
    + Home Assistant (HA) es un software de domótica de código abierto y gratuito, creado en 2013 por Paulus Schoutsen y mantenido hoy bajo la Open Home Foundation. +
    + www.2smarthome.es/blog/home-assistant/ + +
  
+ + 9.  + + + Qué es Home Assistant, para qué sirve y tipos de instalación +
    + Home Assistant es un completo sistema operativo de código abierto, que 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. +
    + seguridadpy.info/2023/10/home-assistant-que-es-tipos-instalacion/ + +
  
+ + 10.  + + + Qué es Home Assistant: Tu Hogar Inteligente en 2026 +
    + En pleno 2026, Home Assistant se ha consolidado como mucho más que un simple asistente para el hogar: es 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. +
    + tecnoyfoto.com/home-assistant-que-es + +
  
+ + + +
+ + + + + + + + + +      + + + + + + + + +
+ + +
+ +

 

+ +
+ + + + + + + + + +
+ +

 

+ + + + +
 
+ + + \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..ff934a4 --- /dev/null +++ b/tests/test_search.py @@ -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 ; eso no debe llegar al + contexto del modelo.""" + resultados = parse_duckduckgo_html(html_real, max_results=10) + for r in resultados: + assert "" not in r.snippet and "" not in r.snippet + assert "<" not in r.title + + +def test_resuelve_entidades_html(html_real): + resultados = parse_duckduckgo_html(html_real, max_results=10) + texto = " ".join(r.title + r.snippet for r in resultados) + assert "&" not in texto and " " not in texto and "&#" not in texto + + +def test_respeta_el_maximo_de_resultados(html_real): + assert len(parse_duckduckgo_html(html_real, max_results=3)) == 3 + assert len(parse_duckduckgo_html(html_real, max_results=1)) == 1 + + +def test_html_sin_resultados_devuelve_lista_vacia(): + assert parse_duckduckgo_html("nada", 5) == [] + + +def test_html_truncado_no_rompe(html_real): + """Una respuesta cortada a la mitad tiene que degradar, no explotar.""" + resultados = parse_duckduckgo_html(html_real[: len(html_real) // 2], max_results=5) + assert isinstance(resultados, list) + + +def test_faltan_snippets_pero_hay_enlaces(): + html = ( + "Uno" + "Dos" + ) + resultados = parse_duckduckgo_html(html, max_results=5) + assert [r.title for r in resultados] == ["Uno", "Dos"] + assert all(r.snippet == "" for r in resultados) + + +def test_descarta_enlaces_que_no_son_http(): + html = "Malo" + assert parse_duckduckgo_html(html, max_results=5) == [] + + +# --- redirecciones -------------------------------------------------------- + + +def test_desenvuelve_la_redireccion_de_duckduckgo(): + """Guardar la redirección en vez del destino ensucia la memoria episódica: + dos búsquedas a la misma página parecerían páginas distintas.""" + envuelto = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fejemplo.com%2Fpagina&rut=abc" + assert _unwrap_redirect(envuelto) == "https://ejemplo.com/pagina" + + +def test_deja_pasar_las_urls_directas(): + assert _unwrap_redirect("https://ejemplo.com/x") == "https://ejemplo.com/x" + + +def test_completa_el_esquema_en_urls_relativas_al_protocolo(): + assert _unwrap_redirect("//ejemplo.com/x") == "https://ejemplo.com/x" + + +# --- presupuesto de contexto ---------------------------------------------- + + +def test_el_snippet_se_recorta_al_presupuesto(): + r = SearchResult(title="T", url="https://e.com", snippet="x" * 1000) + assert len(r.render(max_chars=100)) < 160 + + +def test_render_numera_los_resultados_para_poder_citarlos(): + backend = BackendFalso( + [SearchResult(f"T{i}", f"https://e.com/{i}", f"cuerpo {i}") for i in range(3)] + ) + salida = WebSearch(backend, max_results=3).render("algo") + assert salida.startswith("1. ") and "\n2. " in salida and "\n3. " in salida + + +def test_sin_resultados_lo_dice_en_vez_de_devolver_vacio(): + """El modelo tiene que poder decir que no encontró nada; una cadena vacía + lo empujaría a inventar.""" + assert WebSearch(BackendFalso([])).render("algo") == "Sin resultados." + + +# --- caché ---------------------------------------------------------------- + + +def test_la_cache_evita_repetir_la_consulta_externa(): + backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) + tool = WebSearch(backend, cache_ttl=300.0) + tool.search("misma pregunta") + tool.search("misma pregunta") + assert backend.llamadas == 1 + + +def test_la_cache_no_mezcla_consultas_distintas(): + backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) + tool = WebSearch(backend, cache_ttl=300.0) + tool.search("una") + tool.search("otra") + assert backend.llamadas == 2 + + +def test_cache_ttl_cero_siempre_consulta(): + backend = BackendFalso([SearchResult("T", "https://e.com", "c")]) + tool = WebSearch(backend, cache_ttl=0.0) + tool.search("q") + tool.search("q") + assert backend.llamadas == 2 + + +def test_consulta_vacia_da_error_util(): + with pytest.raises(SearchError, match="vacía"): + WebSearch(BackendFalso([])).search(" ") + + +# --- config --------------------------------------------------------------- + + +def test_la_config_por_defecto_no_necesita_credenciales(): + cfg = load_agent_config() + assert cfg.search.backend == "duckduckgo" + assert cfg.search.region == "es-es" + backend = build_backend(cfg.search) + assert isinstance(backend, DuckDuckGoBackend) + + +def test_searxng_exige_url(): + with pytest.raises(ValueError, match="searxng_url"): + SearchConfig(backend="searxng") + + +def test_searxng_con_url_construye_su_backend(): + cfg = SearchConfig(backend="searxng", searxng_url="http://localhost:8888") + assert isinstance(build_backend(cfg), SearxNGBackend) + + +def test_el_presupuesto_de_contexto_tiene_un_tope_razonable(): + """max_results acotado no es un capricho: con seq_len 2048 los resultados + compiten con la memoria recuperada y el turno del usuario.""" + with pytest.raises(ValueError): + SearchConfig(max_results=100) + + +def test_config_de_agente_inexistente_da_error_util(tmp_path): + with pytest.raises(ConfigError, match="no existe"): + load_agent_config(tmp_path / "no-existe.yaml")