MediaBriefs
SaaS de inteligencia editorial para periodistas
El problema
Un periodista con un programa diario necesita saber dos cosas cada mañana: de qué se está hablando y a quién puede llamar hoy. Ambas las resolvía leyendo medios a mano y cruzando de memoria contra su base de contactos. Y después de cada entrevista, transcribir para citar era otro trabajo manual — con el agravante de que saber quién dijo qué no es un extra en periodismo: es el producto.
Arquitectura
6 feeds RSS peruanos (El Comercio, Gestión, BBC Mundo…)
└─ NewsArticle ────────────────► ~200 artículos de las últimas 24 h
└─ Claude Haiku 4.5 ────────► agrupa en 8–15 temas (clustering semántico)
└─ CÓDIGO ───────────────► mentions, mediaCount, velocity, score, isNew
└─ TopicDefinition + Snapshot ──► histórico temporal del tema
└─ Claude Haiku 4.5 ────────► NER: personas, cargo, organización
└─ PersonProfile ────────► identidad canónica + alias
└─ match contra Guests ──► "este invitado tuyo encaja"
└─ Claude Haiku 4.5 ──► briefing · 3 preguntas · riesgoDecisiones
Identidad canónica vs. observación
Un mismo político aparece como "Pedro Castillo", "pedro castillo" y "P. Castillo" en distintos medios. Si cada variante crea un registro nuevo, el cruce con la base de invitados no funciona nunca. El modelo aporta observaciones (PersonMention, con el nombre tal como apareció y su confianza de extracción); la base mantiene la identidad (PersonProfile, con una normalizedKey sin diacríticos y los alias acumulados). Esa separación permite auditar de dónde salió cada dato y corregir un perfil sin perder el historial.
Self-hostear el modelo que la API no ofrece
Whisper transcribe pero no separa hablantes. Monté pyannote 3.1 en un microservicio FastAPI propio, en su contenedor, para no arrastrar PyTorch, CUDA y HuggingFace a la imagen de Node ni atar el ciclo de despliegue del API a un stack de ML. El backend solo ve una llamada al microservicio que puede fallar sin consecuencias. El merge es el problema interesante: Whisper devuelve texto+tiempos, pyannote devuelve hablante+tiempos, y los cortes no coinciden — cada segmento recibe el hablante con mayor solapamiento temporal.
Whisper ├──── "y eso es lo que el ministro…" ────┤
pyannote ├── SPEAKER_00 ──┤── SPEAKER_01 ──────────┤
↑
asignación por solapamiento dominanteCaché con TTL persistido, no en memoria
Los briefings se cachean 6 horas en MongoDB, no en un Map del proceso. Sobrevive a los reinicios del contenedor, se comparte entre instancias y —al ser multi-usuario— un briefing generado para un periodista sirve para todos los que abran ese tema. El costo por tema tiende a uno cada 6 h, no a uno por usuario por visita.
Salida del modelo que entra a una consulta
El match contra la base es por nombre exacto normalizado o alias registrado, con escapeRegex() sobre la entrada del modelo. Un nombre generado por un LLM que entra a una consulta de Mongo sin escapar es una inyección esperando a pasar.
El producto
Cifras
- 7 600
- líneas de TypeScript en el backendMediaBriefs §1
- 25
- módulos de dominio NestJSMediaBriefs §1
- 61
- endpoints en el contrato OpenAPIMediaBriefs §1
- 88
- esquemas versionadosMediaBriefs §1
- 32
- pantallas en el frontendMediaBriefs §1
- ~200
- artículos procesados cada 2 hMediaBriefs §3
Lo que demuestra
| Habilidad | Evidencia |
|---|---|
| Salidas estructuradas | Tool-use forzado en las tres llamadas · enums en el esquema · índices en vez de texto libre |
| Reparto modelo/código | El LLM agrupa; el código calcula velocity, score y señales editoriales de forma auditable |
| Mitigación de alucinaciones | Referencia por índice · identidad canónica vs. observación · escapeRegex sobre salida del modelo |
| Robustez en producción | Degradación en cascada con cuatro políticas · flag diarizationEnabled visible al usuario · reintentos de Bull |
| Costos | Haiku en todo el pipeline · caché de briefings con TTL persistido y compartido entre usuarios |
| Arquitectura | Microservicio para aislar el stack de ML · 25 módulos NestJS · contrato en repo propio |
Deuda técnica reconocida
Un case study que solo lista aciertos no resiste una pregunta difícil.
- La lógica de merge de diarización está duplicada en TypeScript y Python; el merge acabó ejecutándose en el backend, así que la versión Python es código muerto.
- El clustering hace deleteMany + insertMany: hay una ventana breve con la colección vacía. Un bulkWrite con upserts por label sería más correcto.
- Quedan endurecimientos de seguridad anotados en el código y pendientes de implementar.