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:
2026-07-28 00:39:13 -03:00
parent 5b9f2a58bc
commit 36040d7cc8
8 changed files with 433 additions and 43 deletions
+134 -11
View File
@@ -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: