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.
|
||||
#
|
||||
# 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.
|
||||
# La búsqueda web usa DuckDuckGo como primario: no exige API key ni cuenta, así
|
||||
# que 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.
|
||||
# HTML puede cambiar y hay límite de tasa. Por eso existe la cadena de respaldo.
|
||||
search:
|
||||
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
|
||||
|
||||
# Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados
|
||||
@@ -28,5 +39,6 @@ search:
|
||||
# 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á.
|
||||
# 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,""}
|
||||
|
||||
+134
-11
@@ -147,12 +147,72 @@ class DuckDuckGoBackend:
|
||||
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:
|
||||
"""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.
|
||||
Es el destino natural cuando el volumen de consultas de la casa lo
|
||||
justifique. SearxNG puede agregar DuckDuckGo entre sus fuentes, así que el
|
||||
resultado es equivalente sin exponer las consultas a un tercero.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Tool de búsqueda: backend + presupuesto de contexto + caché.
|
||||
|
||||
@@ -234,15 +339,23 @@ class WebSearch:
|
||||
)
|
||||
|
||||
|
||||
def build_backend(config) -> SearchBackend:
|
||||
"""Construye el backend declarado en la config del agente."""
|
||||
if config.backend == "duckduckgo":
|
||||
def _construir_uno(nombre: str, config) -> SearchBackend:
|
||||
if nombre == "duckduckgo":
|
||||
return DuckDuckGoBackend(
|
||||
region=config.region,
|
||||
timeout=config.timeout,
|
||||
retries=config.retries,
|
||||
region=config.region, 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:
|
||||
raise SearchError(
|
||||
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
|
||||
@@ -250,7 +363,17 @@ def build_backend(config) -> SearchBackend:
|
||||
return SearxNGBackend(
|
||||
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:
|
||||
|
||||
@@ -15,6 +15,8 @@ from typing import Any
|
||||
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from enlace.config.env import load_dotenv
|
||||
|
||||
from enlace.config.schema import AgentConfig, Config, DistillConfig
|
||||
|
||||
# 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():
|
||||
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)
|
||||
if overrides:
|
||||
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
|
||||
|
||||
+40
-11
@@ -202,15 +202,23 @@ class TrainConfig(_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
|
||||
DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend
|
||||
es cambiar este campo.
|
||||
El primario es DuckDuckGo porque no exige credenciales: nada sale a un
|
||||
tercero salvo la consulta misma. Pero su endpoint lite no es una API con
|
||||
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"
|
||||
country: str = "uy"
|
||||
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
|
||||
@@ -221,14 +229,35 @@ class SearchConfig(_Base):
|
||||
retries: int = Field(default=2, ge=0)
|
||||
cache_ttl: float = Field(default=300.0, ge=0)
|
||||
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")
|
||||
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)."
|
||||
)
|
||||
def _check_backends(self) -> SearchConfig:
|
||||
# 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(
|
||||
f"search: el {rol} '{nombre}' exige {requisito[0]} "
|
||||
f"(definí {requisito[1]} en .env)."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,25 @@ def resolve_dtype(name: str) -> torch.dtype:
|
||||
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:
|
||||
if cfg.device == "cpu":
|
||||
return "cpu"
|
||||
@@ -64,12 +83,12 @@ def setup_device(cfg: HardwareConfig) -> torch.device:
|
||||
f"sm_{cap[0]}{cap[1]}."
|
||||
)
|
||||
|
||||
# Turing (sm_75) no tiene unidades bfloat16: PyTorch lo emula y el
|
||||
# entrenamiento se vuelve inservible en vez de fallar. Mejor fallar.
|
||||
if cfg.dtype == "bfloat16" and not torch.cuda.is_bf16_supported():
|
||||
if cfg.dtype == "bfloat16" and not _bf16_nativo():
|
||||
raise HardwareError(
|
||||
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.
|
||||
|
||||
+87
-8
@@ -5,27 +5,97 @@
|
||||
# servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas,
|
||||
# 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 run <config.yaml> [args] # entrena bajo tmux
|
||||
# scripts/remote.sh logs [corrida] # sigue el log en vivo
|
||||
# scripts/remote.sh pull <corrida> # trae métricas y muestras
|
||||
# scripts/remote.sh gpu # estado de la GPU
|
||||
# scripts/remote.sh ssh [comando] # shell o comando suelto
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ -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}"
|
||||
DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env (ver .env.example)}"
|
||||
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:-}"
|
||||
shift || true
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -35,7 +105,8 @@ run)
|
||||
# distintas no se pisan y `logs` sabe a cuál conectarse.
|
||||
sesion="enlace-$(basename "$config" .yaml)"
|
||||
# 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'\" \
|
||||
&& echo 'corriendo en tmux: $sesion'"
|
||||
;;
|
||||
@@ -43,9 +114,9 @@ run)
|
||||
logs)
|
||||
sesion="${1:-}"
|
||||
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
|
||||
ssh "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'"
|
||||
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'"
|
||||
fi
|
||||
;;
|
||||
|
||||
@@ -58,11 +129,19 @@ pull)
|
||||
;;
|
||||
|
||||
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
|
||||
;;
|
||||
esac
|
||||
|
||||
+120
-2
@@ -12,6 +12,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from enlace.agent.tools.search import (
|
||||
BraveBackend,
|
||||
CadenaDeBackends,
|
||||
DuckDuckGoBackend,
|
||||
SearchError,
|
||||
SearchResult,
|
||||
@@ -176,12 +178,18 @@ def test_consulta_vacia_da_error_util():
|
||||
# --- 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()
|
||||
assert cfg.search.backend == "duckduckgo"
|
||||
assert cfg.search.region == "es-es"
|
||||
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():
|
||||
@@ -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):
|
||||
with pytest.raises(ConfigError, match="no existe"):
|
||||
load_agent_config(tmp_path / "no-existe.yaml")
|
||||
|
||||
|
||||
# --- cadena de respaldo ----------------------------------------------------
|
||||
|
||||
|
||||
class BackendQueFalla:
|
||||
name = "roto"
|
||||
|
||||
def __init__(self, mensaje: str = "caído") -> None:
|
||||
self.mensaje = mensaje
|
||||
self.llamadas = 0
|
||||
|
||||
def search(self, query: str, max_results: int) -> list[SearchResult]:
|
||||
self.llamadas += 1
|
||||
raise SearchError(self.mensaje)
|
||||
|
||||
|
||||
def test_la_cadena_usa_el_primario_cuando_anda():
|
||||
primario = BackendFalso([SearchResult("T", "https://e.com", "c")])
|
||||
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||
cadena = CadenaDeBackends([primario, respaldo])
|
||||
|
||||
assert cadena.search("q", 5)[0].title == "T"
|
||||
assert respaldo.llamadas == 0 # no se toca la cuota del respaldo
|
||||
assert cadena.ultimo_backend == "falso"
|
||||
|
||||
|
||||
def test_la_cadena_cae_al_respaldo_ante_un_error():
|
||||
roto = BackendQueFalla("límite de tasa alcanzado")
|
||||
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||
cadena = CadenaDeBackends([roto, respaldo])
|
||||
|
||||
assert cadena.search("q", 5)[0].title == "R"
|
||||
assert roto.llamadas == 1
|
||||
|
||||
|
||||
def test_cero_resultados_NO_dispara_el_respaldo():
|
||||
"""Si el primario respondió bien y no encontró nada, esa es la respuesta.
|
||||
|
||||
Encadenar al siguiente proveedor por esto gastaría cuota y devolvería
|
||||
resultados peores para una consulta que genuinamente no tiene respuesta.
|
||||
"""
|
||||
vacio = BackendFalso([])
|
||||
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||
cadena = CadenaDeBackends([vacio, respaldo])
|
||||
|
||||
assert cadena.search("consulta sin resultados", 5) == []
|
||||
assert respaldo.llamadas == 0
|
||||
|
||||
|
||||
def test_si_fallan_todos_el_error_dice_por_que_cada_uno():
|
||||
cadena = CadenaDeBackends([BackendQueFalla("429"), BackendQueFalla("timeout")])
|
||||
with pytest.raises(SearchError) as exc:
|
||||
cadena.search("q", 5)
|
||||
assert "429" in str(exc.value) and "timeout" in str(exc.value)
|
||||
|
||||
|
||||
def test_la_cadena_recuerda_quien_respondio():
|
||||
"""Al depurar una respuesta rara, lo primero es saber de dónde salió."""
|
||||
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
|
||||
respaldo.name = "respaldo"
|
||||
cadena = CadenaDeBackends([BackendQueFalla(), respaldo])
|
||||
cadena.search("q", 5)
|
||||
assert cadena.ultimo_backend == "respaldo"
|
||||
|
||||
|
||||
def test_una_cadena_vacia_es_un_error():
|
||||
with pytest.raises(SearchError, match="ningún backend"):
|
||||
CadenaDeBackends([])
|
||||
|
||||
|
||||
# --- construcción desde config ---------------------------------------------
|
||||
|
||||
|
||||
def test_un_solo_backend_no_se_envuelve_en_cadena():
|
||||
cfg = SearchConfig(backend="duckduckgo")
|
||||
assert isinstance(build_backend(cfg), DuckDuckGoBackend)
|
||||
|
||||
|
||||
def test_config_con_fallback_arma_la_cadena():
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
|
||||
backend = build_backend(cfg)
|
||||
assert isinstance(backend, CadenaDeBackends)
|
||||
assert [type(b).__name__ for b in backend.backends] == [
|
||||
"DuckDuckGoBackend",
|
||||
"BraveBackend",
|
||||
]
|
||||
|
||||
|
||||
def test_el_fallback_repetido_no_se_duplica():
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["duckduckgo", "brave"], brave_api_key="x")
|
||||
assert cfg.cadena == ["duckduckgo", "brave"]
|
||||
|
||||
|
||||
def test_un_fallback_sin_credencial_falla_al_arrancar():
|
||||
"""No en la primera consulta real — que es justo cuando el primario ya
|
||||
falló y el respaldo tiene que funcionar."""
|
||||
with pytest.raises(ValueError, match="fallback 'brave'"):
|
||||
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||
|
||||
|
||||
def test_brave_como_primario_tambien_exige_credencial():
|
||||
with pytest.raises(ValueError, match="backend 'brave'"):
|
||||
SearchConfig(backend="brave")
|
||||
|
||||
|
||||
def test_la_config_del_repo_declara_brave_de_respaldo():
|
||||
cfg = load_agent_config()
|
||||
assert cfg.search.backend == "duckduckgo"
|
||||
assert "brave" in cfg.search.fallbacks
|
||||
|
||||
Reference in New Issue
Block a user