Saltar al contenido principal

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.

Fran Cobos 14 min de lectura 2789 palabras

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

  1. El audio se sube (MP3, WAV, M4A, FLAC, WebM, OGG) desde el frontend Next.js
  2. El backend FastAPI lo recibe y lanza la transcripción en background (asíncrona)
  3. Gemini 2.0 Flash procesa con un prompt específico para diarización
  4. Gemini devuelve la transcripción con marcas de hablante
  5. 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:

  1. La app de reuniones inserta tickets en la tabla tickets de Supabase
  2. La app de gestión escucha cambios en esa tabla vía Realtime
  3. 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

RolEtiqueta UIPermisos
superadminsuperadminAcceso global: gestiona todas las empresas, todos los usuarios y todas las solicitudes.
company_adminadminGestiona únicamente los usuarios y datos de su propia empresa.
company_userusuarioAcceso 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:

FuncionalidadDetalle
Filtros avanzadosPor prioridad, estado, fecha, grabación de origen
8 temas configurablesPrioridad automática Alta/Media/Baja vía keywords_dict.json
Estadosopen, in_progress, closed
Vista detalleCon la cita original de la transcripción
Edición inlineTítulo, descripción y estado editables directamente

Stack técnico: v1 vs v2 vs v3

Capav1 (Streamlit)v2 (FastAPI + Next.js)v3 (actual — Multitenancy)
FrontendStreamlit + CSS glassmorphismNext.js 15 + React 19 + TypeScript + Tailwind v4 + shadcn/uiIgual + módulo Empresas en Admin
BackendPython modular (scripts)FastAPI + Uvicorn (API REST completa)+ filtrado automático por company_id
AuthSin auth realJWT (python-jose) + bcrypt, 3 rolesRoles: superadmin / company_admin / company_user
Multi-empresaNoNoSí — aislamiento total de datos por tenant
RegistroSin registroRegistro libreFlujo pending → aprobación por admin
Base de datosSupabase PostgreSQL + Storage + RealtimePostgreSQL nativo+ tabla companies + company_id en todas las entidades
DespliegueStreamlit CloudDocker Compose + Coolify + Traefik SSLServidor dedicado montado desde cero (SO + Docker + Coolify)
TranscripciónSíncrona (bloqueante)Asíncrona con BackgroundTasks + pollingIgual
Formatos audioMP3, WAV, M4AMP3, WAV, M4A, FLAC, WebM, OGGIgual
IA transcripciónGemini 1.5 ProGemini 2.0 FlashIgual
Asistente IAOpenAI GPTGemini 2.0 Flash (historial 8 msgs)Igual

Números reales

Métricav1v2v3
Tiempo de transcripción~15s por 10 min de audio~15s por 10 min (async, sin bloquear UI)Igual
Análisis semántico3-5 segundos3-5 segundosIgual
Precisión diarización>90% (2-5 hablantes)>90% (2-5 hablantes)Igual
Formatos de audioMP3, WAV, M4AMP3, WAV, M4A, FLAC, WebM, OGGIgual
Timeout máximoSin control (bloqueante)10 minutos (polling cada 5s)Igual
AuthSin authJWT con 3 rolesRoles multitenancy (superadmin/company_admin/company_user)
Multi-empresaNoNoSí — aislamiento completo por tenant
Registro usuariosSin registroRegistro libreFlujo con aprobación manual
Tiempo de desarrollo1 mes+1 mes (migración)+2 semanas (multitenancy)
DespliegueStreamlit CloudVPS propio (Docker + Coolify)Servidor dedicado montado desde cero

Lo que aprendí

  1. Gemini para diarización funciona — con buenos prompts, compite con servicios dedicados que cuestan 10x más.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Multitenancy no es solo filtrar por usuario — es diseñar desde el principio con company_id en todas las entidades, filtrado automático en el backend y roles que respeten esa jerarquía. Hacerlo a posteriori es costoso.
  7. 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.
  8. 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:

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.