Jeremy Burke
Case study
Contents
Aerial view of Lower Manhattan marked with location pins

Meridian XYZ

OpenCitizen

A local news blog covering the 2025 NYC mayoral primary, powered by an LLM pipeline for research, drafting, and publishing

OpenCitizen ran as a blog during the 2025 NYC mayoral primary, publishing candidate comparisons, debate analysis, and policy breakdowns at a pace made possible by a custom LLM pipeline built underneath it. The pipeline ingested 11 candidates' worth of policy PDFs, campaign websites, news coverage, and debate transcripts, processed them through a structured medallion architecture, and surfaced structured, citable material for drafting and editing. The publication and the pipeline were the same project: the pipeline is what let the blog publish fast and accurately.

Type
Personal / Open Research
Timeline
June – July 2025
Stack
Python · spaCy · FAISS · sentence-transformers · SQLite
Status
Bronze + silver running on real data; gold in progress

Section 01

The Problem

Eleven candidates. Hundreds of articles. No common format was available for comparison.

Even with the volume of reporting that surrounds an electoral campaign, few media outlets present that information in a way that lets readers actually compare candidates' policy proposals side by side. The 2025 NYC mayoral primary produced 11 candidates, dozens of policy PDFs, hundreds of news articles, and debate transcripts. No two candidates published their plans in the same format. Below are some of the source types used in this project:

Source TypeExamplesProblem
Candidate websitesHousing platform pages, bio pagesDifferent structure per candidate; no standard field names
BallotpediaCandidate recordsInconsistent depth; one candidate's page may have 3x the detail of another's
Policy PDFsPlatform documents, position papersNon-machine-readable; no cross-candidate schema
Debate transcriptsPrimary debate footage/transcriptsUnstructured; position statements buried in Q&A
News coverage23+ outlets for the Mamdani win database aloneFraming varies by outlet; no shared sentiment baseline

OpenCitizen was built to be a structured reference for primary voters, synthesizing candidate positions so readers could vote with a clearer picture of what was actually proposed. The pipeline made that publication rate possible: rather than manually assembling context for each post, the structured dataset meant that drafting a comparison piece or debate recap started from organized, citable sources rather than a pile of raw articles. I wanted to organize each story by topic so a reader could follow one issue over time.

Raw scraped source: noisy HTML-derived markdown

Title: Andrew Cuomo to stay in race despite Zohran Mamdani primary win, Adams launches reelection campaign

URL Source: https://abc7ny.com/post/nyc-mayor-andrew-cuomo-stay-race-despite-zohran-mamdani-primary-win-eric-adams-launches-reelection-campaign/16860586/

Published Time: 2025-06-27T23:37:37.000Z

Markdown Content:
NYC mayor: Andrew Cuomo to stay in race despite Zohran Mamdani primary win, Eric Adams launches reelection campaign - ABC7 New York

===============

[![Image 1](https://cdn.abcotvs.net/abcotv/assets/news/global/images/feature-header/wabc-lg.svg)![Image 2](https://cdn.abcotvs.net/abcotv/assets/news/global/images/feature-header/wabc-sm.svg)](https://abc7ny.com/)

[24/7 Live](https://abc7ny.com/watch/live/)[New York City](https://abc7ny.com/new-york/)...

Welcome, Mickey
mickey@disney.com
Manage MyDisney Account
Log Out

*   [POLITICS](https://abc7ny.com/topic/politics/)

Section 02

Solution Architecture

Collection, enrichment, and comparison stay separate so a bad run in one doesn't wreck the others.

When pulling material in from across the web, it was important to keep track of where it came from and when it was sourced, as politicians often change their position on policy issues. The data pipeline splits the work into three stages: collection, enrichment, and comparison, each with its own stage boundaries, configs, and logs. Each artifact was kept in its original state, and transformed information was stored at each stage of the pipeline to make the work auditable. Storing raw source material separately from enriched versions allowed the final output to be citable.

01Data sources

Candidate websitesBallotpediaPolicy PDFsDebate transcriptsNews coverage (23+ outlets)

02Collection and storage

Web scraper + pdf_to_md (candidate-specific scripts)Raw storage: data/reference/candidates/, nl_posts/

03Bronze

bronze_pipeline.py + bronze_config.yamlClean markdown + YAML front matter

04Silver

process_to_silver.py + silver_config.yamlNER + sentiment + chunks

05Gold

process_to_gold.pyCanonical entity IDs

Side-car artifacts: entities.yaml (global entity catalog), kg_edges.jsonl (entity relationship graph)

06Output layer

blog_assistant.pyFAISS semantic searchEntity filtering + citationsPublished content

Section 03

Data Collection

Two dataset layers, one pipeline: candidate reference material and live newsletter content.

The dataset splits into two layers: the candidate reference collection (campaign websites, Ballotpedia pages, PDFs, and full-page web scrapes, collected once per candidate and left unchanged) and the newsletter content (news coverage and debate material, collected as the race progressed). Both feed the same pipeline.

Dataset LayerSourceFormat
Candidate referenceCampaign websitesScraped markdown
Candidate referenceBallotpediaScraped markdown
Candidate referencePolicy PDFspdf_to_md conversion
NewsletterDebate transcriptsScraped markdown
NewsletterNews articlesScraped markdown
NewsletterOpinion / commentaryScraped markdown

The pipeline stores each document in its original form before it is processed. That way, changes to a campaign website over time can be tracked and surfaced in later coverage, especially if the page or policies change.

Section 04

The Pipeline: Bronze, Silver, Gold

A three-stage medallion structure for cleaning, enrichment, and canonicalization.

To get all of the original material into a comparable format, suitable for both AI analysis and publishing, the project uses a three-stage medallion structure: cleaning, enrichment, and canonicalization, with files stored in separate folders at each stage to preserve provenance. Keeping each stage, its config, and its log separate means a bad NER (named entity recognition) run doesn't require redoing earlier work, and a bad cleaning pass shows up before it ever reaches the next stage.

01Raw scraped markdown

HTML artifactsNav cruftAdsImage refsLogin promptsBoilerplate

02Bronze: `bronze_pipeline.py` + `bronze_config.yaml`

Regex noise removal (configurable patterns)Topic tagging (11-topic taxonomy)content_hash generationYAML front matter (title, source, url, date, stage, doc_id, tags)

Logs: bronze_processing.log, processing_report.txt

03Silver: `process_to_silver.py` + `silver_config.yaml`

spaCy en_core_web_sm named entity recognitionCardiffNLP twitter-roberta-base-sentiment-latest sentiment scoringSection chunking at a 1500-character windowKey-point extraction

Logs: silver_processing.log

04Gold: `process_to_gold.py`

Cross-document entity canonicalizationEntity ID resolution ('Mamdani' = 'Zohran Mamdani' = entity_id)

Artifacts: entities.yaml, kg_edges.jsonl. Logs: gold_processing.log

05Output

Queryable dataset for blog_assistant.py

Bronze: Raw to Clean

FieldRaw StateBronze State
Front matterNoneFull YAML block
TitleEmbedded as H1 in bodytitle: in front matter
Source URLURL Source: header lineurl: in front matter
Published dateInline textISO 8601 published_date:
AuthorNoneauthor: field
Content hashNoneMD5 hash; enables skip-on-rerun
Word countNoneComputed word_count:
Stage markerNonestage: bronze
Topic tagsNonetags: [politics, housing, trump, mamdani, adams, cuomo]
Body contentNav links, image refs, login prompts, weather widgets, ad copyStripped to article text only

Silver: Clean to Enriched

FieldBronze (input)Silver (added)
stagebronzesilver
entities(none)Named entities array with type labels (PERSON, ORG, GPE) from spaCy
sentiment(none)Score and label from CardiffNLP model
chunks(none)Array of 1500-character content segments for retrieval
key_points(none)Extracted summary points per chunk
Body contentClean textUnchanged; enrichment adds fields, it doesn't rewrite the text
Why 1500 characters

I tested chunk sizes and settled on 1500 characters. Smaller chunks lost the context around a quote; larger ones made retrieval less precise.

Gold: Enriched to Canonicalized

FieldSilver (input)Gold (added)
stagesilvergold
Entity stringsRaw aliases ("Mamdani," "the Assemblyman from Queens")Canonical entity IDs resolved across all documents
entities.yaml(none)Global entity catalog (sidecar artifact)
kg_edges.jsonl(none)Entity relationship graph; JSONL for incremental append, not full rewrite

The content_hash generated at bronze lets the pipeline skip files it's already processed on rerun. That matters once the collection spans hundreds of documents on different refresh schedules.

Section 05

Taxonomy and Comparison

A fixed 11-topic taxonomy is what makes cross-candidate comparison possible at all.

The pipeline's ability to compare candidates depends entirely on a fixed taxonomy applied consistently at the bronze stage. Without it, "compare housing positions" turns into a research project, because what counts as "housing" shifts depending on the source. With it, every document from every candidate carries the same tag set, and cross-candidate queries become possible.

Topic TagScope
politicsElectoral strategy, endorsements, campaign activity
housingHousing production, affordability, zoning, tenant protections
transportationTransit policy, infrastructure, congestion pricing
healthcare_public_healthHealthcare access, public health policy
campaignCampaign operations, fundraising, ground game
quality_of_lifeParks, noise, neighborhood quality
public_safety_crimePolicing, crime statistics, public safety policy
trumpFederal administration, Trump-NYC dynamics
mamdaniMamdani-specific coverage and positions
adamsAdams-specific coverage and positions
cuomoCuomo-specific coverage and positions

01Fixed 11-topic taxonomy, applied at the bronze stage

politicshousingtransportationhealthcarecampaignquality of lifepublic safetytrumpmamdaniadamscuomo

bronze_pipeline.py, applied at ingestion

02Candidate dataset: 11 candidates

AdamsCuomoLanderMamdaniMyrieStringerTilson+ 4 more

gold entity IDs, cross-document resolution

03Comparison output

Housing query: all 11 candidates, same tag and schemaSentiment by outlet: local vs. national press coverageCross-candidate table: unit targets, timelines, strengths, sources

The taxonomy stays fixed on purpose. Add a topic tag mid-dataset and you break comparability for any query that spans documents from before and after the change.

Section 06

Output: Writing Examples

Debate analysis, a seven-candidate housing comparison, and a sentiment read on Mamdani's win.

Debate Analysis

Published post: the second primary debate

opencitizen.net/p/nyc-dem-primary-debate

The second Democratic primary debate was held June 12, 2025 at John Jay College of Criminal Justice. Seven candidates, thirteen topics, roughly two hours of live television. The raw material is a video file and a transcript that reads nothing like prose: crosstalk, moderator interjections, incomplete sentences, candidates talking over each other, and a lot of filler that gets cut from the version people actually read.

Processing it meant feeding both the transcript and audio through the pipeline to separate each candidate's responses by topic, strip the crosstalk and filler, and produce a clean structured output: one row per candidate per topic, tagged with the speaker name, topic label, and a short summary of their position. The voice analysis step helped resolve the transcript where speaker attribution was ambiguous, which came up more than expected in the sections where candidates interrupted each other directly (the Cuomo/Lander exchange on sexual harassment, the Israel section near the end).

The reason this became one of the clearest examples in the project is that the candidates had no control over the questions. Campaign websites and position papers let you stay vague on anything inconvenient. A debate moderator asking "Would you vote for a rent freeze this year?" forces a yes or no from all seven candidates in the same room. A debate forces every candidate to answer the same question, so the outputs line up. A few of the positions from the debate are below.

CandidateRent freeze (this year)Trump / federal pressurePolice staffingEducation: class-size mandateClose Rikers by 2027
AdamsYesSue if ICE enters Rikers$1.9B in reserves; 2021 cuts were pandemic-wideAccept it; audit contracts, shift funds to classroomsNo
CuomoLeave to Rent Guidelines BoardUsed NYPD size as deterrent as governorAdd 5,000 officersBacked pre-K; state must fund mandates or other programs sufferNo
LanderYesEscorted immigrants past ICE; de-escalation focusHousing First; policing alone won't fix subway incidentsStart with low-income schools; school mergers to share resourcesNo
MamdaniYesEnd NYPD assistance to ICE; restore 200 immigration lawyersWon't defund; wants full agency staffing across the boardFill paraprofessional vacancies; fix 10,000-position shortageNo
MyrieYes$1B state/city rainy-day fund to cover cutsDeputy Mayor for Quality of Life; data-driven coordinationSupports it; wants pension reform (Tier 6) to retain teachersNo
StringerYesAvoid Twitter wars; sue when necessary34-year low; rebuild ranksVoted for it in Albany; add mental-health staff post-COVIDNo
TilsonNoStrategic restraint; federal funds are 10% of budgetConsistent consequences; rebuild ranksOpposes it ($1.6B/yr); prefers high-impact teaching methodsNo

The full analysis covers all thirteen topics. The table above is a sample. The point the pipeline makes visible is that the debate produced a kind of comparable data you can't get from press releases: same question, same room, every candidate on record.

Housing Production: Candidate Comparison

Published post: the housing crisis

opencitizen.net/p/the-housing-crisis

The housing comparison below is the most direct example published during the project, and it covers seven candidates in a single post. Each candidate is presented in the same structure, and doing this by hand would mean reading dozens of source documents and manually pulling out unit targets, timelines, and track records. With the pipeline, every candidate's housing position got tagged with the same taxonomy label at bronze, chunked and keyed at silver, and entity-resolved at gold, so a consistent side-by-side extraction is just a query against the dataset.

NYC needs to build 50,000 to 70,000 new units per year to make a meaningful dent in the housing crisis. In 2024, the city completed 33,974 units. Here's where each candidate's plan lands against that baseline:

CandidateUnit TargetTimeframeRate (units/yr)vs. 2024 ActualKey Strength
Adrienne Adams500,00010 years50,000/yr1.5xLed City of Yes zoning reform; unlocked 120,000 homes as Speaker; secured $8B in affordable housing funding
Andrew Cuomo500,00010 years50,000/yr1.5xImplemented $20B five-year housing plan as governor; HUD Secretary (1997-2001); direct development track record
Brad Lander500,00010 years50,000/yr1.5xSpearheaded Gowanus rezoning (8,500 units); plans to cut ULURP from 7 months to 90 days; $1B in social bonds for 7,000 low-income units
Zohran Mamdani200,00010 years20,000/yr0.6x$100B capital commitment; $70B in new municipal bonds; 100% affordable fast-tracking; community land trust focus
Zellnor Myrie700,00010 years70,000/yr2.1x95,000 mixed-income NYCHA units plus preserve 150,000; authored Housing Stability and Tenant Protection Act
Scott StringerNot specifiedNot specified(none)(none)1,000+ vacant city-owned lots identified (50,000+ potential units); NYCHA audit expertise; ROOF family-sized unit initiative
Whitney Tilson800,00015 years53,000/yr1.6xM1/M2 manufacturing district upzoning (284,000 potential units); models Austin reforms (15% supply surge, 22% rent reduction)

Stringer's plan leaves out a unit total on purpose, focusing instead on financing mechanisms and asset identification. That gap is itself a data point the structured schema makes visible: every other candidate committed to a number, and he didn't.

opencitizen.net/p/the-housing-crisis

Reflections on Mamdani's Win

Published post: reflections on the win

opencitizen.net/p/mamdani-wins

The Mamdani piece takes a different approach from the debate analysis. Instead of extracting candidate positions, it pulls coverage of a single event from 23 outlets, tags each chunk with a source, sentiment score, and topic label, then uses that structure to write one article that accounts for what the press actually said across the board. National coverage of his win was mostly positive. Local coverage was more cautious. The piece holds both without picking a side.

The storytelling follows a fixed path: context, reaction, analysis, counterpoint. That structure doesn't change article to article. What changes is how the writer weights each section and which citations to surface. Writing stays consistent even when the source material isn't, because the organization is decided before the writing starts.

Section 07

Local Event Version Control

Building a structured, queryable record of local events that grows more useful over time.

The underlying goal of OpenCitizen was to show how local events can be tracked and made relevant to the people most affected by them. The information can be published anywhere (a blog, newsletter, or social media app), but the key difference from the traditional news cycle is what's underneath it: a structured, queryable record rather than a pile of one-off posts that disappear into a feed. Building the database first, categorizing events, candidates, and positions as they happened, meant each new piece could layer on top of accumulated context instead of reconstructing it from scratch. Tracking everything by topic makes it possible to hold politicians accountable over time, because the record is persistent rather than scattered across individual stories.

The pipeline and the blog were the same project. The publication rate OpenCitizen achieved during the primary was a direct function of what the data infrastructure made possible.

Section 08

What's Next

From a mayoral-race tracker to a digital twin of the city.

Tracking the NYC mayoral race taught me a great deal about the challenges of publishing real-time content, citing information reliably, and building something that actually helps people make sense of what's happening in their city. The data pipeline and publishing system was ultimately a proof of concept for something larger: a state management system for cities, designed to track and surface the events that matter most to each resident. The goal is a digital twin of NYC that tracks and publishes news as it happens, and turns the city's record into a searchable database rather than something that scrolls past and disappears. As the dataset grows, the planned next phases include more complex cross-topic queries, scenario analysis, and causal modeling of how policy decisions play out across neighborhoods.