Caso Real: SaaS IA que Transcribe Reuniones y Genera Tickets (FastAPI + Next.js + Multitenancy)
Cómo construí iECO: un SaaS multitenancy de IA para reuniones. Múltiples empresas con aislamiento total de datos, roles granulares, flujo de registro con aprobación, panel de gestión de tenants y servidor dedicado montado desde cero con Docker + Coolify + Traefik SSL.
Tabla de contenidos
TL;DR
Construí iECO: un SaaS multitenancy de IA para reuniones. La idea es simple — sube el audio de una reunión, la IA transcribe identificando a cada hablante, extrae oportunidades de negocio y genera tickets automáticos. Cada empresa que usa el sistema tiene sus datos completamente aislados.
El recorrido: empezó como un prototipo con Streamlit + Supabase (v1), lo migré a FastAPI + Next.js 15 + PostgreSQL + Docker + Coolify con auth JWT real (v2), y finalmente lo convertí en un SaaS multitenancy completo con roles granulares, flujo de registro con aprobación y panel de gestión de empresas (v3). Todo el servidor lo monté desde cero: instalación del SO, Docker, Coolify y Traefik SSL.
<video src=“/videos/ieco.mp4” controls muted playsinline class=“w-full rounded-xl my-6 shadow-lg” style=“max-height: 480px; object-fit: cover;”
Tu navegador no soporta vídeo HTML5.
Actualización v2: por qué Streamlit se quedó corto
Cuando arranqué el proyecto, Streamlit era la opción obvia. Es Python puro, te da una interfaz funcional en horas, y para un prototipo de IA es imbatible. Funcionó. Pero con el tiempo apareció una lista de problemas que no tenían solución real dentro del ecosistema Streamlit:
Limitaciones concretas que encontré:
- Sin estado local real: cada interacción recarga la app completa. En una interfaz de transcripción donde el usuario sube un audio y espera, eso genera una UX horrible.
- Autenticación JWT imposible: Streamlit no tiene routing ni sesiones propias. Implementar auth segura con access/refresh tokens es un parche sobre parche.
- Sin roles de usuario: no hay forma limpia de separar vistas por rol (user, admin) sin hacks.
- Timeout en audios largos: el proceso de transcripción bloqueaba el hilo principal de Streamlit, cortando la conexión antes de terminar.
- Personalización visual limitada: glassmorphism funciona, pero hasta cierto punto. Cualquier layout complejo (sidebar, módulos, navegación) requiere CSS inyectado manualmente con
st.markdown. - No escala: al añadir más funcionalidades (panel de admin, dashboard de stats, gestión de usuarios), la app se volvía un solo archivo de 1000+ líneas imposible de mantener.
La decisión fue clara: reescribir el frontend en Next.js 15 y convertir el backend en una API REST real con FastAPI.
El problema real
En entornos comerciales, las reuniones generan mucha información que se pierde. Alguien toma notas (mal), se olvida de apuntar un dato clave, y las oportunidades de negocio se pierden entre correos y libretas.
Lo que se necesitaba:
- Grabar la reunión y obtener una transcripción fiel
- Saber quién dijo qué (diarización)
- Extraer automáticamente las oportunidades comerciales
- Convertir esas oportunidades en tickets gestionables
- Auth real con roles, no acceso libre a todo
- Todo integrado, sin copiar/pegar entre herramientas
La arquitectura v1: dos apps, un ecosistema (Streamlit + Supabase)
La primera versión separaba la lógica en dos apps Streamlit conectadas por Supabase Realtime:
┌───────────────────────┐ ┌───────────────────────┐
│ APP 1: Reuniones │ │ APP 2: Tickets │
│ ───────────────── │ │ ───────────────── │
│ · Grabación audio │ │ · Panel helpdesk │
│ · Transcripción │────────▶│ · Filtros avanzados │
│ · Diarización │Supabase │ · Priorización │
│ · Análisis IA │Realtime │ · Export CSV/Excel │
│ · Asistente GPT │ │ · Operaciones lote │
└───────────────────────┘ └───────────────────────┘
│ │
└──────────┐ ┌────────────────────┘
▼ ▼
┌──────────────┐
│ Supabase │
│ PostgreSQL │
│ + Storage │
│ + Realtime │
└──────────────┘
Funcionó como MVP. Pero los límites de Streamlit aparecieron rápido (ver sección anterior).
La arquitectura v2: FastAPI + Next.js 15 (arquitectura desacoplada real)
La reescritura completa siguió un principio claro: backend como API REST pura, frontend como cliente independiente.
┌─────────────────────────────────────────────────────────┐
│ Frontend — Next.js 15 + React 19 │
│ ┌──────────┐ ┌──────┐ ┌──────────────┐ ┌───────────┐ │
│ │Dashboard │ │Audio │ │Transcripción │ │ Chat IA │ │
│ └──────────┘ └──────┘ └──────────────┘ └───────────┘ │
│ ┌──────────┐ ┌──────┐ ┌──────────────┐ │
│ │ Tickets │ │Admin │ │ Ajustes │ │
│ └──────────┘ └──────┘ └──────────────┘ │
└─────────────────────┬───────────────────────────────────┘
│ JWT auth — API REST
┌─────────────────────▼───────────────────────────────────┐
│ Backend — FastAPI + Uvicorn │
│ · Auth JWT (python-jose) + bcrypt │
│ · Endpoints: grabaciones, transcripción, tickets, chat │
│ · Transcripción asíncrona con BackgroundTasks │
│ · Handler global CORS en errores 500 │
│ · Multitenancy: aislamiento de datos por company_id │
│ · Roles: superadmin / company_admin / company_user │
└─────────────────────┬───────────────────────────────────┘
│
┌───────▼───────┐
│ PostgreSQL │
│ (nativo) │
└───────────────┘
Infraestructura de producción: Docker Compose (backend + frontend en contenedores separados), Coolify para auto-deploy desde git, Traefik como proxy inverso con SSL automático. El servidor dedicado lo configuré yo desde cero: instalación del SO, Docker, Coolify y toda la infraestructura de red.
El reto técnico más difícil: diarización con IA
Transcribir audio es relativamente fácil. Lo difícil es saber quién dijo cada frase. Los servicios comerciales cobran por esto. Yo lo resolví con prompting avanzado en Gemini 2.0 Flash.
Cómo funciona
- El audio se sube (MP3, WAV, M4A, FLAC, WebM, OGG) desde el frontend Next.js
- El backend FastAPI lo recibe y lanza la transcripción en background (asíncrona)
- Gemini 2.0 Flash procesa con un prompt específico para diarización
- Gemini devuelve la transcripción con marcas de hablante
- El frontend hace polling cada 5 segundos hasta recibir el resultado
prompt = """
Transcribe este audio con las siguientes reglas:
1. Identifica cada hablante y asígnale un nombre consistente
2. Si se mencionan nombres, úsalos
3. Si no, usa "Hablante 1", "Hablante 2", etc.
4. Marca cada cambio de hablante con formato [Nombre]:
5. Mantén el texto literal, sin resumir
"""
response = model.generate_content([prompt, audio_file])
Resultado: diarización precisa en reuniones de 2-5 personas, con nombres correctos cuando se mencionan en la conversación.
El problema del timeout (y cómo lo resolví en v2)
En la v1 con Streamlit, las transcripciones largas fallaban porque el proceso bloqueaba el hilo principal. En la v2, el proxy Traefik añadía otro problema: corta conexiones HTTP que superan los 60 segundos.
La solución fue un modelo asíncrono con polling:
1. POST /api/recordings/{id}/transcribe
→ Devuelve inmediatamente: { job_id, status: "processing" }
→ Gemini corre en background (ThreadPoolExecutor)
2. Frontend hace polling cada 5s:
GET /api/transcription-jobs/{job_id}
→ { status: "processing" } (sigue esperando)
→ { status: "completed", transcription } (listo)
→ { status: "error", error: "..." } (falló)
3. Timeout máximo: 120 intentos × 5s = 10 minutos
Sin timeouts. Sin bloqueos. Sin recarga de página. El usuario ve una barra de progreso mientras espera.
Visualización con colores por hablante
Cada hablante recibe un color único en la interfaz. No es solo estético — permite escanear visualmente quién habló más, quién intervino en qué punto, y encontrar fragmentos rápidamente.
Análisis semántico: de texto a tickets
Aquí es donde la IA aporta valor real de negocio. No busco palabras clave — uso Gemini 2.0-Flash para análisis semántico.
Entrada: la transcripción completa de la reunión
Lo que detecta la IA:
- Oportunidades de venta (“nos interesa contratar…”, “necesitamos un proveedor de…”)
- Problemas reportados (“esto no funciona”, “llevamos semanas esperando…”)
- Acciones comprometidas (“te lo mando el lunes”, “vamos a preparar un presupuesto”)
- Seguimientos necesarios (“hay que hablar con el departamento de…”)
Salida: tickets estructurados con título, descripción, prioridad y contexto original.
analysis_prompt = """
Analiza esta transcripción y extrae oportunidades comerciales.
Para cada una, devuelve un JSON con:
- titulo: resumen en una línea
- descripcion: contexto relevante
- prioridad: alta/media/baja
- cita_original: la frase exacta que la justifica
"""
Resultado real: de una reunión de 25 minutos, el sistema extrajo 4 oportunidades con prioridad correcta en 3.5 segundos.
Sincronización en tiempo real
El momento clave: cuando la app de reuniones genera tickets, la app de gestión los muestra instantáneamente. Sin polling, sin refresh manual.
Implementación con Supabase Realtime:
- La app de reuniones inserta tickets en la tabla
ticketsde Supabase - La app de gestión escucha cambios en esa tabla vía Realtime
- Cuando llega un INSERT, el ticket aparece automáticamente en el panel
Esto funciona porque Supabase usa WebSockets internamente. No tuve que montar un servidor WebSocket propio — la infraestructura ya estaba.
El asistente conversacional
Después de transcribir, el usuario puede preguntarle al asistente sobre la reunión:
- “¿Qué dijo Juan sobre el presupuesto?”
- “Resume los puntos principales”
- “¿Se mencionó alguna fecha límite?”
El asistente usa Gemini 2.0 Flash con el contexto de la transcripción y un historial de los últimos 8 mensajes. No es un chatbot genérico — tiene la reunión completa como contexto, así que las respuestas son precisas. La selección de grabación a analizar se hace desde el propio chat.
Auth JWT real: lo que Streamlit no podía hacer
En la v1 no había autenticación real. En la v2, implementé un sistema completo:
- Registro y login con contraseñas hasheadas con bcrypt
- JWT (python-jose) con expiración configurable
- 3 roles:
superadmin,company_admin,company_user - CORS multi-origen configurado para dominios de producción + localhost
- Handler global de excepciones: FastAPI no añade headers CORS en errores 500 sin capturar — lo solucioné con
@app.exception_handler(Exception)para garantizar que el CORS funcione incluso si explota un endpoint de Gemini
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": "Error interno del servidor"},
headers={"Access-Control-Allow-Origin": origin}
)
Multitenancy: múltiples empresas, datos completamente aislados
La v3 añade lo que le faltaba al sistema para ser un producto real: arquitectura multitenancy. No es simplemente filtrar por usuario — es aislar por empresa a nivel de BD con roles diferenciados.
Los tres roles
| Rol | Etiqueta UI | Permisos |
|---|---|---|
superadmin | superadmin | Acceso global: gestiona todas las empresas, todos los usuarios y todas las solicitudes. |
company_admin | admin | Gestiona únicamente los usuarios y datos de su propia empresa. |
company_user | usuario | Acceso estándar: graba, transcribe, chatea y ve sus propios tickets. |
Aislamiento de datos
Cada empresa (tenant) tiene sus grabaciones y tickets completamente separados. La clave está en filtrar por company_id automáticamente en todas las queries del backend:
# Ejemplo: listar grabaciones — solo las de la empresa del usuario autenticado
recordings = db.execute(
"SELECT * FROM recordings WHERE company_id = %s ORDER BY created_at DESC",
(current_user["company_id"],)
)
Un company_admin ve y gestiona únicamente lo de su empresa. El superadmin tiene visibilidad global. Un company_user no puede salirse de su contexto aunque lo intente.
Flujo de registro con aprobación
El registro no es inmediato. Diseñé un flujo deliberado para que las empresas controlen quién entra:
1. Usuario rellena /register (nombre, email, empresa, contraseña)
2. Se crea una solicitud en estado "pending"
3. El company_admin o superadmin la ve en Admin → Solicitudes
4. Aprueba (asigna rol y empresa, crea la cuenta) o rechaza
5. La solicitud desaparece de la lista al instante
6. El usuario aprobado ya puede hacer login
Esto evita que cualquiera con el enlace de registro pueda acceder sin control.
Panel de administración ampliado
El panel de admin pasa de tener dos pestañas a tres:
Solicitudes — lista de registros pendientes con contador en tiempo real en la pestaña. Un clic aprueba y crea el usuario. Otro clic rechaza y descarta.
Usuarios — búsqueda por nombre/email/empresa, filtro por empresa (solo superadmin), crear usuarios directamente, editar nombre/email/rol/empresa, activar/desactivar cuentas y eliminar. Excepción: infra@iautomatiza.net está protegida a nivel de backend y no se puede eliminar bajo ningún concepto.
Empresas (solo superadmin) — crear, editar y eliminar empresas (tenants). Cada empresa tiene nombre y slug único. Desde aquí el superadmin puede ampliar el sistema a nuevos clientes sin tocar código.
Panel de gestión de tickets
El módulo de tickets funciona como un helpdesk/CRM integrado directamente en la misma app:
| Funcionalidad | Detalle |
|---|---|
| Filtros avanzados | Por prioridad, estado, fecha, grabación de origen |
| 8 temas configurables | Prioridad automática Alta/Media/Baja vía keywords_dict.json |
| Estados | open, in_progress, closed |
| Vista detalle | Con la cita original de la transcripción |
| Edición inline | Título, descripción y estado editables directamente |
Stack técnico: v1 vs v2 vs v3
| Capa | v1 (Streamlit) | v2 (FastAPI + Next.js) | v3 (actual — Multitenancy) |
|---|---|---|---|
| Frontend | Streamlit + CSS glassmorphism | Next.js 15 + React 19 + TypeScript + Tailwind v4 + shadcn/ui | Igual + módulo Empresas en Admin |
| Backend | Python modular (scripts) | FastAPI + Uvicorn (API REST completa) | + filtrado automático por company_id |
| Auth | Sin auth real | JWT (python-jose) + bcrypt, 3 roles | Roles: superadmin / company_admin / company_user |
| Multi-empresa | No | No | Sí — aislamiento total de datos por tenant |
| Registro | Sin registro | Registro libre | Flujo pending → aprobación por admin |
| Base de datos | Supabase PostgreSQL + Storage + Realtime | PostgreSQL nativo | + tabla companies + company_id en todas las entidades |
| Despliegue | Streamlit Cloud | Docker Compose + Coolify + Traefik SSL | Servidor dedicado montado desde cero (SO + Docker + Coolify) |
| Transcripción | Síncrona (bloqueante) | Asíncrona con BackgroundTasks + polling | Igual |
| Formatos audio | MP3, WAV, M4A | MP3, WAV, M4A, FLAC, WebM, OGG | Igual |
| IA transcripción | Gemini 1.5 Pro | Gemini 2.0 Flash | Igual |
| Asistente IA | OpenAI GPT | Gemini 2.0 Flash (historial 8 msgs) | Igual |
Números reales
| Métrica | v1 | v2 | v3 |
|---|---|---|---|
| Tiempo de transcripción | ~15s por 10 min de audio | ~15s por 10 min (async, sin bloquear UI) | Igual |
| Análisis semántico | 3-5 segundos | 3-5 segundos | Igual |
| Precisión diarización | >90% (2-5 hablantes) | >90% (2-5 hablantes) | Igual |
| Formatos de audio | MP3, WAV, M4A | MP3, WAV, M4A, FLAC, WebM, OGG | Igual |
| Timeout máximo | Sin control (bloqueante) | 10 minutos (polling cada 5s) | Igual |
| Auth | Sin auth | JWT con 3 roles | Roles multitenancy (superadmin/company_admin/company_user) |
| Multi-empresa | No | No | Sí — aislamiento completo por tenant |
| Registro usuarios | Sin registro | Registro libre | Flujo con aprobación manual |
| Tiempo de desarrollo | 1 mes | +1 mes (migración) | +2 semanas (multitenancy) |
| Despliegue | Streamlit Cloud | VPS propio (Docker + Coolify) | Servidor dedicado montado desde cero |
Lo que aprendí
- Gemini para diarización funciona — con buenos prompts, compite con servicios dedicados que cuestan 10x más.
- Streamlit es para MVPs, no para productos — es la herramienta perfecta para validar una idea en horas, pero si el producto crece, la deuda técnica llega rápido. Sin routing real, sin auth JWT, sin estado local, sin componentización.
- FastAPI + Next.js es la combinación correcta — API REST pura en Python (lo que ya conoces para IA) + frontend moderno en TypeScript. Cada uno hace lo suyo sin compromisos.
- La transcripción asíncrona es obligatoria en producción — cualquier proxy (Traefik, Nginx, Cloudflare) tiene timeouts. Si tu proceso tarda más de 30-60 segundos, necesitas un modelo asíncrono con polling o webhooks.
- CORS en FastAPI requiere cuidado extra — el middleware de CORS solo actúa en respuestas normales. En excepciones no capturadas, los headers desaparecen. Un handler global es imprescindible.
- Multitenancy no es solo filtrar por usuario — es diseñar desde el principio con
company_iden todas las entidades, filtrado automático en el backend y roles que respeten esa jerarquía. Hacerlo a posteriori es costoso. - El flujo de registro con aprobación aporta control real — en un sistema multiempresa, que cualquiera se registre libremente es un problema. El flujo pending → aprobación da a los administradores control total sobre quién entra y con qué rol.
- Montar tu propio servidor desde cero te da control total — instalé el SO, Docker, Coolify y toda la infraestructura yo solo. Más trabajo inicial que un PaaS, pero cero dependencias externas, coste fijo mensual y libertad absoluta para configurar el stack. Coolify + Traefik hacen que el deploy y los certificados SSL sean casi automáticos una vez está montado.
¿Necesitas algo similar?
Si tu empresa necesita automatizar el procesamiento de reuniones, integrar IA en flujos de trabajo o construir sistemas que conecten varias aplicaciones en tiempo real, hablemos.
Otros casos reales: