Brave como respaldo de DuckDuckGo, y arreglo de la guardia de bf16
Cadena de búsqueda con respaldo. DuckDuckGo sigue de primario porque no exige credenciales: en el camino habitual ningún tercero se entera de qué busca la familia. Pero su endpoint lite no es una API con contrato, así que cuando limite por tasa o cambie el HTML, Brave responde. Dos matices del encadenado: - Cero resultados NO dispara el respaldo. Si el primario respondió bien y no encontró nada, esa es la respuesta correcta; encadenar gastaría cuota y devolvería resultados peores. Solo se avanza ante un error real. - La cadena recuerda quién respondió: al depurar una respuesta rara, lo primero que hay que saber es de dónde salió. Un respaldo sin credencial falla al arrancar y no en la primera consulta — que es justo cuando el primario ya falló y el respaldo tiene que funcionar. Aparte, verificando torch en la 2060 apareció un defecto real en backends.py: torch.cuda.is_bf16_supported() usa including_emulation=True por defecto, así que devuelve True en Turing, donde bf16 no existe en silicio. La guardia no habría atrapado el perfil de la 5090 corriendo en la 2060: el entrenamiento seguía, emulado y silencioso. Medido en la placa: 4,49 ms por matmul en bf16 contra 1,75 ms en fp16, unas 2,6x más lento. Ahora se consulta con including_emulation=False, con respaldo por compute capability. 11 tests nuevos (113 en total). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"ROS2.distro": "jazzy"
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
# Herramientas del agente.
|
# Herramientas del agente.
|
||||||
#
|
#
|
||||||
# La búsqueda web usa DuckDuckGo, que no exige API key ni cuenta en ningún
|
# La búsqueda web usa DuckDuckGo como primario: no exige API key ni cuenta, así
|
||||||
# servicio: no hay credenciales que gestionar ni un tercero al que se le informe
|
# que no hay credenciales que gestionar ni un tercero al que se le informe qué
|
||||||
# qué busca la familia.
|
# busca la familia.
|
||||||
#
|
#
|
||||||
# Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer")
|
# Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer")
|
||||||
# solo devuelve definiciones y fichas de entidades, no resultados web. Los
|
# 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
|
# 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`,
|
# HTML puede cambiar y hay límite de tasa. Por eso existe la cadena de respaldo.
|
||||||
# autoalojado y estable, como reemplazo de una línea cuando haga falta.
|
|
||||||
search:
|
search:
|
||||||
backend: duckduckgo
|
backend: duckduckgo
|
||||||
region: es-es
|
|
||||||
|
# Se prueban en orden, y solo ante un error real del anterior. Cero
|
||||||
|
# resultados no dispara el respaldo: si el primario respondió bien y no
|
||||||
|
# encontró nada, esa es la respuesta correcta.
|
||||||
|
#
|
||||||
|
# Brave sí es una API con contrato y límites claros, pero cada consulta pasa
|
||||||
|
# por un tercero asociado a una cuenta — por eso va de respaldo y no de
|
||||||
|
# primario. SearxNG autoalojado es el destino natural cuando el volumen lo
|
||||||
|
# justifique: agrega DuckDuckGo entre sus fuentes sin exponer las consultas.
|
||||||
|
fallbacks: [brave]
|
||||||
|
|
||||||
|
region: es-es # DuckDuckGo
|
||||||
|
country: uy # Brave
|
||||||
language: es
|
language: es
|
||||||
|
|
||||||
# Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados
|
# Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados
|
||||||
@@ -28,5 +39,6 @@ search:
|
|||||||
# idénticas en pocos minutos.
|
# idénticas en pocos minutos.
|
||||||
cache_ttl: 300.0
|
cache_ttl: 300.0
|
||||||
|
|
||||||
# Solo se usa con backend=searxng. Sale de .env y queda vacío si no está.
|
# Credenciales, desde .env. Quedan vacías si la variable no está definida.
|
||||||
|
brave_api_key: ${oc.env:ENLACE_BRAVE_API_KEY,""}
|
||||||
searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""}
|
searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""}
|
||||||
|
|||||||
+134
-11
@@ -147,12 +147,72 @@ class DuckDuckGoBackend:
|
|||||||
raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}")
|
raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}")
|
||||||
|
|
||||||
|
|
||||||
|
class BraveBackend:
|
||||||
|
"""Brave Search API. Una API con contrato, a diferencia del endpoint lite.
|
||||||
|
|
||||||
|
Existe como red de seguridad del primario: cuando DuckDuckGo limite por tasa
|
||||||
|
o cambie el HTML, esto responde. La contrapartida es que cada consulta pasa
|
||||||
|
por un tercero asociado a una cuenta, así que no se usa como primario salvo
|
||||||
|
que se elija explícitamente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "brave"
|
||||||
|
ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, api_key: str, timeout: float = 10.0, country: str = "uy", language: str = "es"
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.country = country
|
||||||
|
self.language = language
|
||||||
|
|
||||||
|
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||||
|
url = self.ENDPOINT + "?" + urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"q": query,
|
||||||
|
"count": max_results,
|
||||||
|
"country": self.country,
|
||||||
|
"search_lang": self.language,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Subscription-Token": self.api_key,
|
||||||
|
"User-Agent": _USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as respuesta:
|
||||||
|
payload = json.loads(respuesta.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# 429 es el caso interesante: si el fallback también está limitado,
|
||||||
|
# el mensaje tiene que decirlo en vez de parecer un fallo genérico.
|
||||||
|
detalle = "límite de tasa alcanzado" if exc.code == 429 else f"HTTP {exc.code}"
|
||||||
|
raise SearchError(f"Brave rechazó la consulta: {detalle}") from None
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise SearchError(f"Brave no respondió: {exc}") from None
|
||||||
|
|
||||||
|
resultados = payload.get("web", {}).get("results", [])
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
title=_clean(item.get("title", "")),
|
||||||
|
url=item.get("url", ""),
|
||||||
|
snippet=_clean(item.get("description", "")),
|
||||||
|
)
|
||||||
|
for item in resultados[:max_results]
|
||||||
|
if item.get("url", "").startswith("http")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SearxNGBackend:
|
class SearxNGBackend:
|
||||||
"""SearxNG autoalojado: la opción estable y sin depender de terceros.
|
"""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
|
Es el destino natural cuando el volumen de consultas de la casa lo
|
||||||
volumen de consultas de la casa lo justifique. SearxNG puede agregar
|
justifique. SearxNG puede agregar DuckDuckGo entre sus fuentes, así que el
|
||||||
DuckDuckGo entre sus fuentes, así que el resultado es equivalente.
|
resultado es equivalente sin exponer las consultas a un tercero.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "searxng"
|
name = "searxng"
|
||||||
@@ -183,6 +243,51 @@ class SearxNGBackend:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CadenaDeBackends:
|
||||||
|
"""Prueba los backends en orden y devuelve el primero que responda.
|
||||||
|
|
||||||
|
La razón de existir: el endpoint lite de DuckDuckGo no es una API con
|
||||||
|
contrato — puede limitar por tasa o cambiar el HTML sin aviso. Con esto un
|
||||||
|
fallo deja de ser una respuesta vacía para la familia y pasa a ser un
|
||||||
|
reintento contra otro proveedor.
|
||||||
|
|
||||||
|
Dos matices que importan:
|
||||||
|
|
||||||
|
- **Cero resultados no es un fallo.** Si el primario responde bien y no
|
||||||
|
encontró nada, esa es la respuesta correcta; encadenar al siguiente
|
||||||
|
proveedor por eso gastaría cuota y devolvería resultados peores.
|
||||||
|
Solo se avanza ante un error real.
|
||||||
|
- **Se recuerda quién respondió** (`ultimo_backend`), porque al depurar
|
||||||
|
una respuesta rara lo primero que hay que saber es de dónde salió.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, backends: list[SearchBackend]) -> None:
|
||||||
|
if not backends:
|
||||||
|
raise SearchError("la cadena de búsqueda no tiene ningún backend")
|
||||||
|
self.backends = backends
|
||||||
|
self.ultimo_backend: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return " -> ".join(getattr(b, "name", type(b).__name__) for b in self.backends)
|
||||||
|
|
||||||
|
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||||
|
fallos: list[str] = []
|
||||||
|
for backend in self.backends:
|
||||||
|
nombre = getattr(backend, "name", type(backend).__name__)
|
||||||
|
try:
|
||||||
|
resultados = backend.search(query, max_results)
|
||||||
|
except SearchError as exc:
|
||||||
|
fallos.append(f"{nombre}: {exc}")
|
||||||
|
continue
|
||||||
|
self.ultimo_backend = nombre
|
||||||
|
return resultados
|
||||||
|
|
||||||
|
raise SearchError(
|
||||||
|
"ningún backend de búsqueda respondió — " + " | ".join(fallos)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WebSearch:
|
class WebSearch:
|
||||||
"""Tool de búsqueda: backend + presupuesto de contexto + caché.
|
"""Tool de búsqueda: backend + presupuesto de contexto + caché.
|
||||||
|
|
||||||
@@ -234,15 +339,23 @@ class WebSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_backend(config) -> SearchBackend:
|
def _construir_uno(nombre: str, config) -> SearchBackend:
|
||||||
"""Construye el backend declarado en la config del agente."""
|
if nombre == "duckduckgo":
|
||||||
if config.backend == "duckduckgo":
|
|
||||||
return DuckDuckGoBackend(
|
return DuckDuckGoBackend(
|
||||||
region=config.region,
|
region=config.region, timeout=config.timeout, retries=config.retries
|
||||||
timeout=config.timeout,
|
|
||||||
retries=config.retries,
|
|
||||||
)
|
)
|
||||||
if config.backend == "searxng":
|
if nombre == "brave":
|
||||||
|
if not config.brave_api_key:
|
||||||
|
raise SearchError(
|
||||||
|
"backend brave sin brave_api_key: definí ENLACE_BRAVE_API_KEY en .env"
|
||||||
|
)
|
||||||
|
return BraveBackend(
|
||||||
|
api_key=config.brave_api_key,
|
||||||
|
timeout=config.timeout,
|
||||||
|
country=config.country,
|
||||||
|
language=config.language,
|
||||||
|
)
|
||||||
|
if nombre == "searxng":
|
||||||
if not config.searxng_url:
|
if not config.searxng_url:
|
||||||
raise SearchError(
|
raise SearchError(
|
||||||
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
|
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
|
||||||
@@ -250,7 +363,17 @@ def build_backend(config) -> SearchBackend:
|
|||||||
return SearxNGBackend(
|
return SearxNGBackend(
|
||||||
base_url=config.searxng_url, timeout=config.timeout, language=config.language
|
base_url=config.searxng_url, timeout=config.timeout, language=config.language
|
||||||
)
|
)
|
||||||
raise SearchError(f"backend de búsqueda desconocido: {config.backend}")
|
raise SearchError(f"backend de búsqueda desconocido: {nombre}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_backend(config) -> SearchBackend:
|
||||||
|
"""Construye la cadena declarada en la config: primario + respaldos.
|
||||||
|
|
||||||
|
Con un solo backend devuelve ese backend pelado, para no envolver en una
|
||||||
|
cadena algo que no la necesita.
|
||||||
|
"""
|
||||||
|
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
|
||||||
|
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
|
||||||
|
|
||||||
|
|
||||||
def build_search(config) -> WebSearch:
|
def build_search(config) -> WebSearch:
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from typing import Any
|
|||||||
|
|
||||||
from omegaconf import DictConfig, OmegaConf
|
from omegaconf import DictConfig, OmegaConf
|
||||||
|
|
||||||
|
from enlace.config.env import load_dotenv
|
||||||
|
|
||||||
from enlace.config.schema import AgentConfig, Config, DistillConfig
|
from enlace.config.schema import AgentConfig, Config, DistillConfig
|
||||||
|
|
||||||
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
|
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
|
||||||
@@ -114,6 +116,11 @@ def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: s
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise ConfigError(f"no existe el archivo de config: {path}")
|
raise ConfigError(f"no existe el archivo de config: {path}")
|
||||||
|
|
||||||
|
# `${oc.env:...}` lee os.environ al resolver, así que .env tiene que estar
|
||||||
|
# cargado antes: si no, una credencial bien escrita en el archivo se
|
||||||
|
# resuelve a vacío y el backend parece mal configurado.
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
merged = _load_yaml(path)
|
merged = _load_yaml(path)
|
||||||
if overrides:
|
if overrides:
|
||||||
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
|
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
|
||||||
|
|||||||
+38
-9
@@ -202,15 +202,23 @@ class TrainConfig(_Base):
|
|||||||
|
|
||||||
|
|
||||||
class SearchConfig(_Base):
|
class SearchConfig(_Base):
|
||||||
"""Búsqueda web. Sin API key: DuckDuckGo no exige credenciales.
|
"""Búsqueda web, con cadena de respaldo.
|
||||||
|
|
||||||
`searxng` existe como alternativa autoalojada para cuando el endpoint de
|
El primario es DuckDuckGo porque no exige credenciales: nada sale a un
|
||||||
DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend
|
tercero salvo la consulta misma. Pero su endpoint lite no es una API con
|
||||||
es cambiar este campo.
|
contrato — puede limitar por tasa o cambiar el HTML sin aviso — así que
|
||||||
|
`fallbacks` define a quién preguntarle cuando eso pase.
|
||||||
|
|
||||||
|
El orden importa y es explícito: se prueba `backend` y después cada entrada
|
||||||
|
de `fallbacks`, en orden, hasta que alguno responda.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
backend: Literal["duckduckgo", "searxng"] = "duckduckgo"
|
backend: Literal["duckduckgo", "brave", "searxng"] = "duckduckgo"
|
||||||
|
fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
region: str = "es-es"
|
region: str = "es-es"
|
||||||
|
country: str = "uy"
|
||||||
language: str = "es"
|
language: str = "es"
|
||||||
max_results: int = Field(default=5, gt=0, le=20)
|
max_results: int = Field(default=5, gt=0, le=20)
|
||||||
# Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados
|
# Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados
|
||||||
@@ -221,13 +229,34 @@ class SearchConfig(_Base):
|
|||||||
retries: int = Field(default=2, ge=0)
|
retries: int = Field(default=2, ge=0)
|
||||||
cache_ttl: float = Field(default=300.0, ge=0)
|
cache_ttl: float = Field(default=300.0, ge=0)
|
||||||
searxng_url: str | None = None
|
searxng_url: str | None = None
|
||||||
|
brave_api_key: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cadena(self) -> list[str]:
|
||||||
|
"""El primario seguido de los respaldos, sin repetidos."""
|
||||||
|
orden = [self.backend, *self.fallbacks]
|
||||||
|
vistos: list[str] = []
|
||||||
|
for nombre in orden:
|
||||||
|
if nombre not in vistos:
|
||||||
|
vistos.append(nombre)
|
||||||
|
return vistos
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_backend(self) -> SearchConfig:
|
def _check_backends(self) -> SearchConfig:
|
||||||
if self.backend == "searxng" and not self.searxng_url:
|
# Un backend sin credencial se descubriría recién en la primera consulta
|
||||||
|
# real, que es justo cuando el primario ya falló y el respaldo tiene que
|
||||||
|
# funcionar. Se valida al arrancar.
|
||||||
|
requisitos = {
|
||||||
|
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
|
||||||
|
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
|
||||||
|
}
|
||||||
|
for nombre in self.cadena:
|
||||||
|
requisito = requisitos.get(nombre)
|
||||||
|
if requisito and not getattr(self, requisito[0]):
|
||||||
|
rol = "backend" if nombre == self.backend else "fallback"
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"search.backend='searxng' exige searxng_url "
|
f"search: el {rol} '{nombre}' exige {requisito[0]} "
|
||||||
"(definí ENLACE_SEARXNG_URL en .env)."
|
f"(definí {requisito[1]} en .env)."
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ def resolve_dtype(name: str) -> torch.dtype:
|
|||||||
return _DTYPES[name]
|
return _DTYPES[name]
|
||||||
|
|
||||||
|
|
||||||
|
def _bf16_nativo() -> bool:
|
||||||
|
"""¿La GPU tiene unidades bfloat16 de verdad, o PyTorch lo emula?
|
||||||
|
|
||||||
|
Trampa verificada en hardware: `torch.cuda.is_bf16_supported()` usa
|
||||||
|
`including_emulation=True` por defecto, así que devuelve True en Turing —
|
||||||
|
donde bf16 no existe en silicio y se emula. Medido en una RTX 2060, esa
|
||||||
|
emulación es ~2,6x más lenta que float16 (4,49 vs 1,75 ms por matmul de
|
||||||
|
2048x2048). Confiar en el default deja pasar el perfil equivocado y el
|
||||||
|
entrenamiento corre a un tercio de velocidad sin una sola advertencia.
|
||||||
|
|
||||||
|
bfloat16 nativo llega con Ampere (sm_80).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return torch.cuda.is_bf16_supported(including_emulation=False)
|
||||||
|
except TypeError:
|
||||||
|
# Versiones de torch anteriores al parámetro: se decide por capability.
|
||||||
|
return torch.cuda.get_device_capability(0) >= (8, 0)
|
||||||
|
|
||||||
|
|
||||||
def describe_device(cfg: HardwareConfig) -> str:
|
def describe_device(cfg: HardwareConfig) -> str:
|
||||||
if cfg.device == "cpu":
|
if cfg.device == "cpu":
|
||||||
return "cpu"
|
return "cpu"
|
||||||
@@ -64,12 +83,12 @@ def setup_device(cfg: HardwareConfig) -> torch.device:
|
|||||||
f"sm_{cap[0]}{cap[1]}."
|
f"sm_{cap[0]}{cap[1]}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Turing (sm_75) no tiene unidades bfloat16: PyTorch lo emula y el
|
if cfg.dtype == "bfloat16" and not _bf16_nativo():
|
||||||
# entrenamiento se vuelve inservible en vez de fallar. Mejor fallar.
|
|
||||||
if cfg.dtype == "bfloat16" and not torch.cuda.is_bf16_supported():
|
|
||||||
raise HardwareError(
|
raise HardwareError(
|
||||||
f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) "
|
f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) "
|
||||||
"no soporta de forma nativa. Usá float16 con use_grad_scaler=true."
|
"no soporta de forma nativa: PyTorch lo emula. Medido en una RTX 2060, "
|
||||||
|
"la emulación es ~2,6x más lenta que float16. "
|
||||||
|
"Usá float16 con use_grad_scaler=true."
|
||||||
)
|
)
|
||||||
|
|
||||||
# FlashAttention-2 requiere Ampere o superior.
|
# FlashAttention-2 requiere Ampere o superior.
|
||||||
|
|||||||
+87
-8
@@ -5,27 +5,97 @@
|
|||||||
# servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas,
|
# servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas,
|
||||||
# muestras generadas y logs.
|
# muestras generadas y logs.
|
||||||
#
|
#
|
||||||
|
# Dos direcciones para la misma máquina: la de LAN se prueba primero porque es
|
||||||
|
# el caso habitual y es mucho más rápida para rsync; si no responde, se cae a la
|
||||||
|
# pública. Forzar una con ENLACE_REMOTE_FORCE=lan|wan.
|
||||||
|
#
|
||||||
|
# scripts/remote.sh check # conectividad, GPU y entorno
|
||||||
# scripts/remote.sh sync # empuja el código al servidor
|
# scripts/remote.sh sync # empuja el código al servidor
|
||||||
# scripts/remote.sh run <config.yaml> [args] # entrena bajo tmux
|
# scripts/remote.sh run <config.yaml> [args] # entrena bajo tmux
|
||||||
# scripts/remote.sh logs [corrida] # sigue el log en vivo
|
# scripts/remote.sh logs [corrida] # sigue el log en vivo
|
||||||
# scripts/remote.sh pull <corrida> # trae métricas y muestras
|
# scripts/remote.sh pull <corrida> # trae métricas y muestras
|
||||||
# scripts/remote.sh gpu # estado de la GPU
|
# scripts/remote.sh gpu # estado de la GPU
|
||||||
|
# scripts/remote.sh ssh [comando] # shell o comando suelto
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
[[ -f .env ]] && set -a && source .env && set +a
|
[[ -f .env ]] && set -a && source .env && set +a
|
||||||
|
|
||||||
HOST="${ENLACE_REMOTE_HOST:?definí ENLACE_REMOTE_HOST en .env (ver .env.example)}"
|
DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env (ver .env.example)}"
|
||||||
DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env}"
|
|
||||||
PY="${ENLACE_REMOTE_PYTHON:-$DIR/.venv/bin/python}"
|
PY="${ENLACE_REMOTE_PYTHON:-$DIR/.venv/bin/python}"
|
||||||
|
|
||||||
|
# Timeout corto: si no estamos en la LAN, no tiene sentido esperar el TCP.
|
||||||
|
LAN_TIMEOUT="${ENLACE_LAN_TIMEOUT:-3}"
|
||||||
|
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
_alcanzable() {
|
||||||
|
ssh "${SSH_OPTS[@]}" -o "ConnectTimeout=$1" "$2" true 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
elegir_host() {
|
||||||
|
local lan="${ENLACE_REMOTE_LAN_HOST:-}" wan="${ENLACE_REMOTE_HOST:-}"
|
||||||
|
|
||||||
|
case "${ENLACE_REMOTE_FORCE:-}" in
|
||||||
|
lan) echo "${lan:?ENLACE_REMOTE_FORCE=lan pero ENLACE_REMOTE_LAN_HOST no está definido}"; return ;;
|
||||||
|
wan) echo "${wan:?ENLACE_REMOTE_FORCE=wan pero ENLACE_REMOTE_HOST no está definido}"; return ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$lan" ]] && _alcanzable "$LAN_TIMEOUT" "$lan"; then
|
||||||
|
echo "$lan"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -n "$wan" ]]; then
|
||||||
|
echo "$wan"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "definí ENLACE_REMOTE_LAN_HOST o ENLACE_REMOTE_HOST en .env" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
HOST="$(elegir_host)"
|
||||||
|
# A stderr para no contaminar la salida de los comandos que se parsean.
|
||||||
|
if [[ "$HOST" == "${ENLACE_REMOTE_LAN_HOST:-}" ]]; then
|
||||||
|
echo "[enlace] servidor: $HOST (LAN)" >&2
|
||||||
|
else
|
||||||
|
echo "[enlace] servidor: $HOST (remoto)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
comando="${1:-}"
|
comando="${1:-}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
case "$comando" in
|
case "$comando" in
|
||||||
|
check)
|
||||||
|
# Todo lo que tiene que estar bien antes de comprometer horas de cómputo o
|
||||||
|
# gigabytes de corpus. Falla ruidoso y temprano.
|
||||||
|
ssh "${SSH_OPTS[@]}" "$HOST" bash -s -- "$DIR" "$PY" <<'REMOTO'
|
||||||
|
set -u
|
||||||
|
DIR="$1"; PY="$2"
|
||||||
|
echo "host: $(hostname)"
|
||||||
|
echo "repo: $DIR $( [ -d "$DIR/.git" ] && echo '(ok)' || echo '(FALTA: cloná el repo)')"
|
||||||
|
echo "python: $( [ -x "$PY" ] && "$PY" --version 2>&1 || echo 'FALTA el venv')"
|
||||||
|
echo "tmux: $(command -v tmux >/dev/null && tmux -V || echo 'FALTA (las corridas largas lo necesitan)')"
|
||||||
|
if command -v nvidia-smi >/dev/null; then
|
||||||
|
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader | sed 's/^/gpu: /'
|
||||||
|
else
|
||||||
|
echo "gpu: nvidia-smi no está en PATH"
|
||||||
|
fi
|
||||||
|
[ -x "$PY" ] && "$PY" - <<'PY'
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
print("torch: no instalado (corré `remote.sh sync`)"); raise SystemExit
|
||||||
|
print(f"torch: {torch.__version__} | cuda disponible: {torch.cuda.is_available()}")
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
cap = torch.cuda.get_device_capability(0)
|
||||||
|
print(f" sm_{cap[0]}{cap[1]} | bf16: {torch.cuda.is_bf16_supported()}")
|
||||||
|
print(f" perfil sugerido: {'blackwell-5090' if cap >= (12,0) else 'turing-2060'}")
|
||||||
|
PY
|
||||||
|
REMOTO
|
||||||
|
;;
|
||||||
|
|
||||||
sync)
|
sync)
|
||||||
git push
|
git push
|
||||||
ssh "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ."
|
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
run)
|
run)
|
||||||
@@ -35,7 +105,8 @@ run)
|
|||||||
# distintas no se pisan y `logs` sabe a cuál conectarse.
|
# distintas no se pisan y `logs` sabe a cuál conectarse.
|
||||||
sesion="enlace-$(basename "$config" .yaml)"
|
sesion="enlace-$(basename "$config" .yaml)"
|
||||||
# tmux es lo que hace que cortar el SSH no mate el entrenamiento.
|
# tmux es lo que hace que cortar el SSH no mate el entrenamiento.
|
||||||
ssh -t "$HOST" "cd '$DIR' && tmux new-session -d -s '$sesion' \
|
ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && mkdir -p runs && \
|
||||||
|
tmux new-session -d -s '$sesion' \
|
||||||
\"$PY -m enlace.train.train '$config' $* 2>&1 | tee -a 'runs/$sesion.log'\" \
|
\"$PY -m enlace.train.train '$config' $* 2>&1 | tee -a 'runs/$sesion.log'\" \
|
||||||
&& echo 'corriendo en tmux: $sesion'"
|
&& echo 'corriendo en tmux: $sesion'"
|
||||||
;;
|
;;
|
||||||
@@ -43,9 +114,9 @@ run)
|
|||||||
logs)
|
logs)
|
||||||
sesion="${1:-}"
|
sesion="${1:-}"
|
||||||
if [[ -z "$sesion" ]]; then
|
if [[ -z "$sesion" ]]; then
|
||||||
ssh "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f"
|
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f"
|
||||||
else
|
else
|
||||||
ssh "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'"
|
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
@@ -58,11 +129,19 @@ pull)
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
gpu)
|
gpu)
|
||||||
ssh "$HOST" "nvidia-smi"
|
ssh "${SSH_OPTS[@]}" "$HOST" "nvidia-smi"
|
||||||
|
;;
|
||||||
|
|
||||||
|
ssh)
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && exec \$SHELL -l"
|
||||||
|
else
|
||||||
|
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && $*"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
sed -n '2,14p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+120
-2
@@ -12,6 +12,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from enlace.agent.tools.search import (
|
from enlace.agent.tools.search import (
|
||||||
|
BraveBackend,
|
||||||
|
CadenaDeBackends,
|
||||||
DuckDuckGoBackend,
|
DuckDuckGoBackend,
|
||||||
SearchError,
|
SearchError,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
@@ -176,12 +178,18 @@ def test_consulta_vacia_da_error_util():
|
|||||||
# --- config ---------------------------------------------------------------
|
# --- config ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_la_config_por_defecto_no_necesita_credenciales():
|
def test_el_primario_no_necesita_credenciales():
|
||||||
|
"""Lo que importa del default: la primera consulta sale sin credenciales.
|
||||||
|
|
||||||
|
La cadena puede tener respaldos que sí las exijan, pero el camino habitual
|
||||||
|
no le informa a ningún tercero qué busca la familia.
|
||||||
|
"""
|
||||||
cfg = load_agent_config()
|
cfg = load_agent_config()
|
||||||
assert cfg.search.backend == "duckduckgo"
|
assert cfg.search.backend == "duckduckgo"
|
||||||
assert cfg.search.region == "es-es"
|
assert cfg.search.region == "es-es"
|
||||||
backend = build_backend(cfg.search)
|
backend = build_backend(cfg.search)
|
||||||
assert isinstance(backend, DuckDuckGoBackend)
|
primero = backend.backends[0] if isinstance(backend, CadenaDeBackends) else backend
|
||||||
|
assert isinstance(primero, DuckDuckGoBackend)
|
||||||
|
|
||||||
|
|
||||||
def test_searxng_exige_url():
|
def test_searxng_exige_url():
|
||||||
@@ -204,3 +212,113 @@ def test_el_presupuesto_de_contexto_tiene_un_tope_razonable():
|
|||||||
def test_config_de_agente_inexistente_da_error_util(tmp_path):
|
def test_config_de_agente_inexistente_da_error_util(tmp_path):
|
||||||
with pytest.raises(ConfigError, match="no existe"):
|
with pytest.raises(ConfigError, match="no existe"):
|
||||||
load_agent_config(tmp_path / "no-existe.yaml")
|
load_agent_config(tmp_path / "no-existe.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
# --- cadena de respaldo ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BackendQueFalla:
|
||||||
|
name = "roto"
|
||||||
|
|
||||||
|
def __init__(self, mensaje: str = "caído") -> None:
|
||||||
|
self.mensaje = mensaje
|
||||||
|
self.llamadas = 0
|
||||||
|
|
||||||
|
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||||
|
self.llamadas += 1
|
||||||
|
raise SearchError(self.mensaje)
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cadena_usa_el_primario_cuando_anda():
|
||||||
|
primario = BackendFalso([SearchResult("T", "https://e.com", "c")])
|
||||||
|
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||||
|
cadena = CadenaDeBackends([primario, respaldo])
|
||||||
|
|
||||||
|
assert cadena.search("q", 5)[0].title == "T"
|
||||||
|
assert respaldo.llamadas == 0 # no se toca la cuota del respaldo
|
||||||
|
assert cadena.ultimo_backend == "falso"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cadena_cae_al_respaldo_ante_un_error():
|
||||||
|
roto = BackendQueFalla("límite de tasa alcanzado")
|
||||||
|
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||||
|
cadena = CadenaDeBackends([roto, respaldo])
|
||||||
|
|
||||||
|
assert cadena.search("q", 5)[0].title == "R"
|
||||||
|
assert roto.llamadas == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cero_resultados_NO_dispara_el_respaldo():
|
||||||
|
"""Si el primario respondió bien y no encontró nada, esa es la respuesta.
|
||||||
|
|
||||||
|
Encadenar al siguiente proveedor por esto gastaría cuota y devolvería
|
||||||
|
resultados peores para una consulta que genuinamente no tiene respuesta.
|
||||||
|
"""
|
||||||
|
vacio = BackendFalso([])
|
||||||
|
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||||
|
cadena = CadenaDeBackends([vacio, respaldo])
|
||||||
|
|
||||||
|
assert cadena.search("consulta sin resultados", 5) == []
|
||||||
|
assert respaldo.llamadas == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_fallan_todos_el_error_dice_por_que_cada_uno():
|
||||||
|
cadena = CadenaDeBackends([BackendQueFalla("429"), BackendQueFalla("timeout")])
|
||||||
|
with pytest.raises(SearchError) as exc:
|
||||||
|
cadena.search("q", 5)
|
||||||
|
assert "429" in str(exc.value) and "timeout" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cadena_recuerda_quien_respondio():
|
||||||
|
"""Al depurar una respuesta rara, lo primero es saber de dónde salió."""
|
||||||
|
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||||
|
respaldo.name = "respaldo"
|
||||||
|
cadena = CadenaDeBackends([BackendQueFalla(), respaldo])
|
||||||
|
cadena.search("q", 5)
|
||||||
|
assert cadena.ultimo_backend == "respaldo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_cadena_vacia_es_un_error():
|
||||||
|
with pytest.raises(SearchError, match="ningún backend"):
|
||||||
|
CadenaDeBackends([])
|
||||||
|
|
||||||
|
|
||||||
|
# --- construcción desde config ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_solo_backend_no_se_envuelve_en_cadena():
|
||||||
|
cfg = SearchConfig(backend="duckduckgo")
|
||||||
|
assert isinstance(build_backend(cfg), DuckDuckGoBackend)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_con_fallback_arma_la_cadena():
|
||||||
|
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
|
||||||
|
backend = build_backend(cfg)
|
||||||
|
assert isinstance(backend, CadenaDeBackends)
|
||||||
|
assert [type(b).__name__ for b in backend.backends] == [
|
||||||
|
"DuckDuckGoBackend",
|
||||||
|
"BraveBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_fallback_repetido_no_se_duplica():
|
||||||
|
cfg = SearchConfig(backend="duckduckgo", fallbacks=["duckduckgo", "brave"], brave_api_key="x")
|
||||||
|
assert cfg.cadena == ["duckduckgo", "brave"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_fallback_sin_credencial_falla_al_arrancar():
|
||||||
|
"""No en la primera consulta real — que es justo cuando el primario ya
|
||||||
|
falló y el respaldo tiene que funcionar."""
|
||||||
|
with pytest.raises(ValueError, match="fallback 'brave'"):
|
||||||
|
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_brave_como_primario_tambien_exige_credencial():
|
||||||
|
with pytest.raises(ValueError, match="backend 'brave'"):
|
||||||
|
SearchConfig(backend="brave")
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_config_del_repo_declara_brave_de_respaldo():
|
||||||
|
cfg = load_agent_config()
|
||||||
|
assert cfg.search.backend == "duckduckgo"
|
||||||
|
assert "brave" in cfg.search.fallbacks
|
||||||
|
|||||||
Reference in New Issue
Block a user