
De la teoría a la producción: arquitectura híbrida en Angular con detección de capacidades de hardware, monitorización de descarga de modelos y fallback resiliente en el Edge.
El dilema de la IA en Salud: Privacidad médica y facturas en la nube
Si alguna vez has desplegado una funcionalidad basada en Grandes Modelos de Lenguaje (LLMs) en producción, ya conoces la pesadilla recurrente: la factura de tokens a final de mes y la latencia de red.
En nuestro proyecto, Prospecto Fácil, el problema era aún más delicado. Un prospecto oficial de la Agencia Española de Medicamentos y Productos Sanitarios (AEMPS) puede superar fácilmente las 4.000 o 6.000 palabras de texto técnico denso. Para un paciente de edad avanzada o alguien que busca de urgencia si puede tomar ibuprofeno con fiebre alta, leer ese maratón de contraindicaciones es una barrera insalvable.
El instinto inicial de cualquier desarrollador en 2026 es directo: «Envía el texto a una API externa de OpenAI, Anthropic o Google Gemini en un endpoint serverless y devuélvele un resumen en viñetas».
Sin embargo, en el ámbito de la salud digital, ese enfoque choca frontalmente contra dos muros:
- Privacidad médica y RGPD: Si un usuario pega un fragmento de prospecto o consulta qué hacer con su medicación, enviar esos datos a un servidor centralizado de inferencia expone información sanitaria protegida.
- Coste e infraestructura: Multiplicar 5.000 tokens de entrada por decenas de miles de visitas mensuales significa pagar facturas recurrentes solo para que un LLM extraiga tres puntos clave de un texto que el usuario ya tiene descargado en su pantalla.
¿Y si el propio navegador del usuario pudiera resumir el documento en local, en su propia CPU/NPU, con latencia mínima, sin enviar un solo byte a ningún servidor y con coste de inferencia cero?
Bienvenido a la Chrome Built-in AI y la Summarizer API.
¿Qué es la Chrome Built-in AI y la Summarizer API?
Bajo la iniciativa de Web AI del W3C Web Incubator Community Group (WICG), Google ha integrado modelos fundacionales ligeros (específicamente variantes de Gemini Nano) directamente en el motor Chromium.
Esto significa que el modelo reside en el sistema operativo o en la caché del navegador. Los desarrolladores no tenemos que empaquetar un archivo .onnx de 4 GB con WebAssembly ni sobrecargar la conexión del usuario: el navegador expone APIs JavaScript de alto nivel (window.Summarizer, window.ai.languageModel, window.Writer, window.Rewriter) que atacan directamente el hardware del dispositivo.

En nuestro proyecto diseñamos una arquitectura híbrida de dos niveles:
- Nivel 1 (Prioritario): Inferencia en local mediante Gemini Nano con la Summarizer API de Chrome. Cero coste, máxima privacidad.
- Nivel 2 (Fallback transparente): Si el usuario utiliza Safari, Firefox, un navegador móvil sin soporte o un equipo antiguo, la petición se delega a una Edge Function en Cloudflare Pages (
/api/summarize) con guardrails estrictos y llamadas a Gemini en la nube.
Veamos cómo implementamos esta solución en producción con Angular y TypeScript.
Detección de Capacidades y Manejo del Ciclo de Vida del Modelo
La API de IA integrada en Chrome ha evolucionado desde los primeros Origin Trials (window.ai.summarizer) hacia la especificación global estandarizada (window.Summarizer). Un código robusto debe soportar ambas interfaces para maximizar la compatibilidad.
El primer paso es comprobar la disponibilidad del modelo en el cliente. La API define tres estados fundamentales:
'readily'/'available': El modelo ya está descargado y listo para inferir al instante.'after-download'/'downloadable': El dispositivo es compatible, pero el navegador necesita descargar los pesos de Gemini Nano (típicamente entre 1 y 1.5 GB en segundo plano).'no'/'unavailable': El hardware no cumple los requisitos mínimos de memoria/GPU o el navegador no soporta la API.
En chrome-ai-summarizer.service.ts encapsulamos esta lógica usando Angular Signals:
import { Injectable, signal } from '@angular/core';
export type ChromeAiAvailability = 'available' | 'downloadable' | 'unavailable';
export interface ChromeAiSummarizerOptions {
type?: 'key-points' | 'tldr' | 'teaser' | 'headline';
format?: 'plain-text' | 'markdown';
length?: 'short' | 'medium' | 'long';
outputLanguage?: string;
expectedInputLanguages?: string[];
sharedContext?: string;
context?: string;
}
@Injectable({
providedIn: 'root',
})
export class ChromeAiSummarizerService {
/** Indica si la API está expuesta globalmente en el navegador actual */
readonly isSupported = signal<boolean>(false);
/** Estado actual de disponibilidad del modelo en el dispositivo */
readonly availability = signal<ChromeAiAvailability>('unavailable');
/** Progreso de descarga del modelo (0 - 100) en caso de requerir descarga */
readonly downloadProgress = signal<number | null>(null);
/** Indica si hay un proceso de inferencia activo */
readonly isSummarizing = signal<boolean>(false);
private cachedSummarizerInstance: any = null;
constructor() {
this.detectSupport();
}
detectSupport(): boolean {
if (typeof window === 'undefined') {
this.isSupported.set(false);
return false;
}
const win = window as any;
const hasModern = Boolean(win.Summarizer) && typeof win.Summarizer?.create === 'function';
const hasLegacy =
Boolean(win.ai) &&
typeof win.ai === 'object' &&
'summarizer' in win.ai &&
typeof win.ai.summarizer?.create === 'function';
const supported = Boolean(hasModern || hasLegacy);
this.isSupported.set(supported);
return supported;
}
Comprobación de disponibilidad con soporte en español
Un reto importante es asegurar que el modelo soporte el idioma de nuestra aplicación (español). La API moderna permite pasar opciones a availability() para verificar capacidades lingüísticas:
async checkAvailability(
options: ChromeAiSummarizerOptions = {
type: 'key-points',
format: 'plain-text',
length: 'medium',
outputLanguage: 'es',
expectedInputLanguages: ['es'],
}
): Promise<ChromeAiAvailability> {
if (!this.detectSupport()) {
this.availability.set('unavailable');
return 'unavailable';
}
const win = window as any;
try {
// 1. API Moderna estándar: Summarizer.availability(options)
if (Boolean(win.Summarizer) && typeof win.Summarizer?.availability === 'function') {
const status = await win.Summarizer.availability(options);
if (status === 'available' || status === 'readily') {
this.availability.set('available');
return 'available';
}
if (status === 'downloadable' || status === 'after-download') {
this.availability.set('downloadable');
return 'downloadable';
}
this.availability.set('unavailable');
return 'unavailable';
}
// 2. Fallback previo Origin Trial: ai.summarizer.capabilities()
if (win.ai?.summarizer?.capabilities) {
const caps = await win.ai.summarizer.capabilities();
if (caps?.available === 'readily') {
this.availability.set('available');
return 'available';
}
if (caps?.available === 'after-download') {
this.availability.set('downloadable');
return 'downloadable';
}
}
} catch (err) {
console.warn('Error al verificar disponibilidad de Chrome Summarizer API:', err);
}
this.availability.set('unavailable');
return 'unavailable';
}
Inferencia Local y Monitorización de Descarga en Segundo Plano
Cuando un usuario interactúa por primera vez con la funcionalidad en un dispositivo compatible, el navegador puede necesitar descargar los binarios de Gemini Nano.
Si dejas la interfaz congelada sin feedback, el usuario pensará que la aplicación se ha roto. La API resuelve esto permitiendo adjuntar un monitor de progreso al método .create() :
async summarize(
text: string,
options: ChromeAiSummarizerOptions = {
type: 'key-points',
format: 'plain-text',
length: 'medium',
outputLanguage: 'es',
expectedInputLanguages: ['es'],
}
): Promise<string> {
if (!text?.trim()) throw new Error('El texto para resumir está vacío.');
const win = window as any;
this.isSummarizing.set(true);
try {
let summarizer = this.cachedSummarizerInstance;
if (!summarizer) {
// Monitor de progreso de descarga del modelo
const monitorCallback = (monitor: any) => {
if (monitor?.addEventListener) {
monitor.addEventListener('downloadprogress', (e: any) => {
if (e.total && e.total > 0) {
const percent = Math.round((e.loaded / e.total) * 100);
this.downloadProgress.set(percent);
} else if (e.loaded != null) {
this.downloadProgress.set(Math.round(e.loaded * 100));
}
});
}
};
const createConfig: any = {
type: options.type || 'key-points',
format: options.format || 'plain-text',
length: options.length || 'medium',
expectedInputLanguages: ['es'],
outputLanguage: 'es',
sharedContext: 'Resumen en español del prospecto oficial de un medicamento aprobado por la AEMPS.',
monitor: monitorCallback,
};
// Instanciación con fallback de compatibilidad sintáctica
if (Boolean(win.Summarizer)) {
summarizer = await win.Summarizer.create(createConfig);
} else {
summarizer = await win.ai.summarizer.create(createConfig);
}
this.cachedSummarizerInstance = summarizer;
}
this.downloadProgress.set(null);
// Contexto e instrucción explícita de anclaje para el SLM
const promptInSpanish = `Instrucción: Genera el resumen exclusivamente en español en lenguaje claro y accesible para pacientes.\n\n${text}`;
return await summarizer.summarize(promptInSpanish, {
context: options.context || 'Resumen en español del prospecto oficial:',
});
} finally {
this.isSummarizing.set(false);
}
}
💡TIP
Lección aprendida en producción: Los modelos pequeños en el dispositivo (SLMs) como Gemini Nano son extremadamente eficientes, pero tienen menor tolerancia a ambigüedades lingüísticas que modelos gigantescos como Gemini 1.5 Pro. Incluir tanto elsharedContextdurante elcreate()como un prefijo imperativo (Instrucción: Genera el resumen exclusivamente en español...) garantiza que la respuesta no derive al inglés o mezcle términos científicos innecesarios.
El Orquestador Resiliente: MedicationSummarizerService
Tener una API moderna en Chrome es fantástico, pero un porcentaje de usuarios entrará desde Safari en iOS, Firefox en Linux o Chrome en un smartphone con memoria ajustada.
Para resolverlo, creamos una fachada orquestadora en medication-summarizer.service.ts que actúa como Decision Tree:
@Injectable({ providedIn: 'root' })
export class MedicationSummarizerService {
private readonly chromeAi = inject(ChromeAiSummarizerService);
readonly isSummarizing = signal<boolean>(false);
readonly error = signal<string | null>(null);
readonly lastResult = signal<SummarizeResult | null>(null);
async summarize(
text: string,
medicationName?: string,
sectionTitle?: string
): Promise<SummarizeResult | null> {
const cleanText = text?.trim() || '';
if (cleanText.length < 25) {
this.error.set('El texto seleccionado es demasiado breve para resumir.');
return null;
}
this.isSummarizing.set(true);
this.error.set(null);
// ESTRATEGIA 1: Probar Chrome AI local (Gemini Nano)
if (this.chromeAi.isSupported()) {
try {
const availability = await this.chromeAi.checkAvailability();
if (availability === 'available' || availability === 'downloadable') {
const rawLocal = await this.chromeAi.summarize(cleanText, {
type: 'key-points',
format: 'plain-text',
length: 'medium',
outputLanguage: 'es',
sharedContext: `Medicamento: ${medicationName}. Sección: ${sectionTitle}.`,
});
if (rawLocal?.trim()) {
const points = this.extractBulletPoints(rawLocal);
const result: SummarizeResult = {
isValid: true,
generalSummary: points[0] || rawLocal,
keyPoints: points.length > 1 ? points.slice(1) : points,
source: 'local-nano', // <-- Etiqueta de procedencia
};
this.lastResult.set(result);
this.isSummarizing.set(false);
return result;
}
}
} catch (localErr) {
console.warn('Chrome AI local no disponible, activando fallback en Edge:', localErr);
}
}
// ESTRATEGIA 2: Fallback transparente a Cloudflare Pages Function
try {
const response = await fetch('/api/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: cleanText, medicationName, sectionTitle }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const result: SummarizeResult = {
isValid: data.es_valido,
generalSummary: data.resumen_general,
keyPoints: data.puntos_clave || [],
patientActions: data.que_debe_hacer_el_paciente,
warnings: data.advertencias_o_alarmas,
source: data.fuente || 'gemini',
};
this.lastResult.set(result);
return result;
} catch (err: any) {
this.error.set('No se pudo generar el resumen en este momento.');
return null;
} finally {
this.isSummarizing.set(false);
}
}
}
La Red de Seguridad en el Edge: Cloudflare Function con Guardrails Médicos
Cuando la petición viaja a nuestro fallback (functions/api/summarize.ts), no la dejamos desprotegida. Los prospectos médicos son un terreno donde las alucinaciones de un LLM o los ataques de prompt injection pueden inducir a errores farmacológicos graves.
Nuestra Cloudflare Function implementa múltiples capas de defensa en el Edge:
- Rate Limiting en memoria por IP: Máximo 10 peticiones/minuto por cliente para prevenir abusos de scraping sobre la API de Gemini.
- Detección Pre-LLM de Prompt Injection: Expresiones regulares que descartan intentos de manipulación («ignora las instrucciones anteriores», «developer mode», etc.) antes de invocar el modelo.
- Structured Outputs con JSON Schema: Forzamos a la API a devolver un JSON tipado con campos obligatorios:
es_valido,resumen_general,puntos_clave,que_debe_hacer_el_pacienteyadvertencias_o_alarmas. - Validación semántica: Si el texto enviado no corresponde a un prospecto médico real (por ejemplo, si alguien intenta usar nuestro endpoint como traductor general o generador de código), el validador lo detecta y lo rechaza con
es_valido: false.
// Fragmento de functions/api/summarize.ts
const INJECTION_PATTERNS = [
/(?:ignore|olvida|ignora|skip|disregard|forget)\s+(?:previous|anteriores|todas)/i,
/(system\s*prompt|developer\s*mode|jailbreak|dan\s*mode)/i,
/(ahora\s+eres|you\s+are\s+now|act\s+as)/i,
];
export const onRequestPost = async (context: any) => {
const { request, env } = context;
const ip = request.headers.get('cf-connecting-ip') || '';
if (!checkRateLimit(ip)) {
return new Response(JSON.stringify({ error: 'Demasiadas solicitudes. Espera un minuto.' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
const payload = await request.json();
if (INJECTION_PATTERNS.some(p => p.test(payload.text))) {
return new Response(JSON.stringify({ es_valido: false, motivo_rechazo: 'Texto no médico o no admitido.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Llamada segura a Google Gemini en Cloudflare Pages
// ...
};
La Experiencia de Usuario: Transparencia y Privacidad Visible
Una de las mayores victorias de diseño en este desarrollo fue la reacción de los usuarios al saber que la IA corría en su propio dispositivo.
En la interfaz de Prospecto Fácil, cuando el resumen se genera mediante Gemini Nano local, mostramos una pequeña insignia:
@if (result.source === 'local-nano') {
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
IA Local (100% Privada · Gemini Nano)
</span>
} @else {
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full bg-blue-50 text-blue-700 border border-blue-200">
Resumen Asistido por IA (Cloudflare Edge)
</span>
}
¿Merece la pena apostar por Web AI?
La integración de modelos de lenguaje en el propio navegador marca un punto de inflexión en el desarrollo web moderno. Deja de ser necesario elegir entre ofrecer funcionalidades inteligentes o mantener costes contenidos.
Diseñar aplicaciones Local-First con degradación elegante hacia el Edge es el patrón definitivo para la nueva era de la web con IA.
¿Quieres probar la experiencia en vivo? Abre cualquier medicamento en Prospecto Fácil desde una versión reciente de Google Chrome con soporte de IA integrada y pulsa en «Resumir con IA».