Back to home

MediaBriefs

Editorial intelligence SaaS for journalists

Role
AI Engineer & Full-Stack Developer
Status
In production

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

pipelinetext
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 · risk
The full editorial pipeline. The transcription pipeline runs in parallel.

Decisions

01

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.

02

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 overlap
03

Persisted 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.

04

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

The journalist's newsroom: the day's schedule, reminders, and the insights panel that cross-references activity against what the editorial radar picked up.
Before scheduling, the system validates the critical requirements and suggests — rather than enforces — prioritising a direct reminder channel. The AI advises; the journalist decides.
A two-step flow: base details first, final review second.
Every show carries its editorial mandate. That text is context for the model when it writes briefings: the same topic is framed differently on a morning magazine show than in a long-form analysis interview.
The database that people detected in the news are matched against. Matching is by normalized name or registered alias — hence the split between canonical identity and observation.
Four views over the same schedule: list, calendar, timeline and kanban.
The entry point of the transcription pipeline: paste a link — YouTube, Vimeo, Twitter or direct — and get back a transcript segmented by speaker.
Account and workspace settings.

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

SkillEvidence
Structured outputsForced tool-use on all three calls · enums in the schema · indices instead of free text
Model/code splitThe LLM groups; the code computes velocity, score and editorial signals auditably
Hallucination mitigationIndex references · canonical identity vs. observation · escapeRegex on model output
Production robustnessCascading degradation with four policies · diarizationEnabled flag surfaced to the user · Bull retries
Cost controlHaiku across the pipeline · briefing cache with persisted TTL shared between users
ArchitectureMicroservice 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.

Stack

  • NestJS
  • TypeScript
  • Node.js
  • MongoDB
  • Redis + Bull
  • Session auth with rotation
  • OpenAPI 3
  • Next.js
  • React
  • Tailwind
  • Claude Haiku 4.5
  • Whisper
  • pyannote 3.1
  • FastAPI
  • PyTorch
  • Docker