MediaBriefs
Editorial intelligence SaaS for journalists
The problem
A journalist running a daily show needs two things every morning: what people are talking about, and who they can call today. Both were solved by reading outlets by hand and cross-referencing a contact list from memory. And after every interview, transcribing for quotes was more manual work — with the added catch that in journalism, knowing who said what isn't a bonus feature: it's the product.
Architecture
6 Peruvian RSS feeds (El Comercio, Gestión, BBC Mundo…)
└─ NewsArticle ────────────────► ~200 articles from the last 24 h
└─ Claude Haiku 4.5 ────────► groups into 8–15 topics
└─ CODE ─────────────────► mentions, mediaCount, velocity, score
└─ TopicDefinition + Snapshot ──► topic history over time
└─ Claude Haiku 4.5 ────────► NER: people, role, organisation
└─ PersonProfile ────────► canonical identity + aliases
└─ match against Guests ──► "this guest of yours fits"
└─ Claude Haiku 4.5 ──► briefing · 3 questions · riskDecisions
Canonical identity vs. observation
The same politician shows up as "Pedro Castillo", "pedro castillo" and "P. Castillo" across outlets. If every variant creates a new record, matching against the guest database never works. The model contributes observations (PersonMention, with the name exactly as it appeared and an extraction confidence); the database maintains identity (PersonProfile, keyed on a diacritic-stripped normalizedKey with aliases accumulated). That separation is what lets me audit where each piece of data came from and fix a profile without losing history.
Self-host the model the API does not offer
Whisper transcribes but doesn't separate speakers. I put pyannote 3.1 in its own FastAPI microservice and container, so PyTorch, CUDA and HuggingFace never enter the Node image and the API's deploy cycle isn't tied to an ML stack. The backend only sees a call to the microservice that is allowed to fail. The merge is the interesting problem: Whisper returns text+timings, pyannote returns speaker+timings, and the boundaries don't line up — each segment takes the speaker with the dominant temporal overlap.
Whisper ├──── "and that's what the minister…" ────┤
pyannote ├── SPEAKER_00 ──┤── SPEAKER_01 ─────────────┤
↑
assigned by dominant overlapPersisted TTL cache, not in-process
Briefings are cached for 6 hours in MongoDB, not in a process-local Map. It survives container restarts, is shared across instances, and — being multi-tenant — a briefing generated for one journalist serves everyone who opens that topic. Cost per topic tends toward one every 6 hours, not one per user per visit.
Model output that reaches a query
Matching against the database is by exact normalized name or registered alias, with escapeRegex() applied to the model’s output. An LLM-generated name reaching a Mongo query unescaped is an injection waiting to happen.
The product
Figures
- 7,600
- lines of TypeScript in the backendMediaBriefs §1
- 25
- NestJS domain modulesMediaBriefs §1
- 61
- endpoints in the OpenAPI contractMediaBriefs §1
- 88
- versioned schemasMediaBriefs §1
- 32
- frontend screensMediaBriefs §1
- ~200
- articles processed every 2 hMediaBriefs §3
What it demonstrates
| Skill | Evidence |
|---|---|
| Structured outputs | Forced tool-use on all three calls · enums in the schema · indices instead of free text |
| Model/code split | The LLM groups; the code computes velocity, score and editorial signals auditably |
| Hallucination mitigation | Index references · canonical identity vs. observation · escapeRegex on model output |
| Production robustness | Cascading degradation with four policies · diarizationEnabled flag surfaced to the user · Bull retries |
| Cost control | Haiku across the pipeline · briefing cache with persisted TTL shared between users |
| Architecture | Microservice isolating the ML stack · 25 NestJS modules · contract in its own repo |
Known technical debt
A case study that only lists wins does not survive a hard question.
- Diarization merge logic is duplicated in TypeScript and Python; the merge ended up running in the backend, so the Python version is dead code.
- Clustering does deleteMany + insertMany: there is a brief window where the collection is empty. A bulkWrite with upserts by label would be more correct.
- Some security hardening is noted in the code and still pending implementation.