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.
Section 01
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 Type | Examples | Problem |
|---|---|---|
| Candidate websites | Housing platform pages, bio pages | Different structure per candidate; no standard field names |
| Ballotpedia | Candidate records | Inconsistent depth; one candidate's page may have 3x the detail of another's |
| Policy PDFs | Platform documents, position papers | Non-machine-readable; no cross-candidate schema |
| Debate transcripts | Primary debate footage/transcripts | Unstructured; position statements buried in Q&A |
| News coverage | 23+ outlets for the Mamdani win database alone | Framing 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 =============== [](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
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
02Collection and storage
pdf_to_md (candidate-specific scripts)Raw storage: data/reference/candidates/, nl_posts/03Bronze
bronze_pipeline.py + bronze_config.yamlClean markdown + YAML front matter04Silver
process_to_silver.py + silver_config.yamlNER + sentiment + chunks05Gold
process_to_gold.pyCanonical entity IDsSide-car artifacts: entities.yaml (global entity catalog), kg_edges.jsonl (entity relationship graph)
06Output layer
blog_assistant.pyFAISS semantic searchEntity filtering + citationsPublished contentSection 03
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 Layer | Source | Format |
|---|---|---|
| Candidate reference | Campaign websites | Scraped markdown |
| Candidate reference | Ballotpedia | Scraped markdown |
| Candidate reference | Policy PDFs | pdf_to_md conversion |
| Newsletter | Debate transcripts | Scraped markdown |
| Newsletter | News articles | Scraped markdown |
| Newsletter | Opinion / commentary | Scraped 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
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
02Bronze: `bronze_pipeline.py` + `bronze_config.yaml`
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`
en_core_web_sm named entity recognitionCardiffNLP twitter-roberta-base-sentiment-latest sentiment scoringSection chunking at a 1500-character windowKey-point extractionLogs: silver_processing.log
04Gold: `process_to_gold.py`
Artifacts: entities.yaml, kg_edges.jsonl. Logs: gold_processing.log
05Output
blog_assistant.py| Field | Raw State | Bronze State |
|---|---|---|
| Front matter | None | Full YAML block |
| Title | Embedded as H1 in body | title: in front matter |
| Source URL | URL Source: header line | url: in front matter |
| Published date | Inline text | ISO 8601 published_date: |
| Author | None | author: field |
| Content hash | None | MD5 hash; enables skip-on-rerun |
| Word count | None | Computed word_count: |
| Stage marker | None | stage: bronze |
| Topic tags | None | tags: [politics, housing, trump, mamdani, adams, cuomo] |
| Body content | Nav links, image refs, login prompts, weather widgets, ad copy | Stripped to article text only |
| Field | Bronze (input) | Silver (added) |
|---|---|---|
stage | bronze | silver |
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 content | Clean text | Unchanged; enrichment adds fields, it doesn't rewrite the text |
I tested chunk sizes and settled on 1500 characters. Smaller chunks lost the context around a quote; larger ones made retrieval less precise.
| Field | Silver (input) | Gold (added) |
|---|---|---|
stage | silver | gold |
| Entity strings | Raw 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
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 Tag | Scope |
|---|---|
politics | Electoral strategy, endorsements, campaign activity |
housing | Housing production, affordability, zoning, tenant protections |
transportation | Transit policy, infrastructure, congestion pricing |
healthcare_public_health | Healthcare access, public health policy |
campaign | Campaign operations, fundraising, ground game |
quality_of_life | Parks, noise, neighborhood quality |
public_safety_crime | Policing, crime statistics, public safety policy |
trump | Federal administration, Trump-NYC dynamics |
mamdani | Mamdani-specific coverage and positions |
adams | Adams-specific coverage and positions |
cuomo | Cuomo-specific coverage and positions |
01Fixed 11-topic taxonomy, applied at the bronze stage
bronze_pipeline.py, applied at ingestion
02Candidate dataset: 11 candidates
gold entity IDs, cross-document resolution
03Comparison output
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
Debate analysis, a seven-candidate housing comparison, and a sentiment read on Mamdani's win.
Published post: the second 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.
| Candidate | Rent freeze (this year) | Trump / federal pressure | Police staffing | Education: class-size mandate | Close Rikers by 2027 |
|---|---|---|---|---|---|
| Adams | Yes | Sue if ICE enters Rikers | $1.9B in reserves; 2021 cuts were pandemic-wide | Accept it; audit contracts, shift funds to classrooms | No |
| Cuomo | Leave to Rent Guidelines Board | Used NYPD size as deterrent as governor | Add 5,000 officers | Backed pre-K; state must fund mandates or other programs suffer | No |
| Lander | Yes | Escorted immigrants past ICE; de-escalation focus | Housing First; policing alone won't fix subway incidents | Start with low-income schools; school mergers to share resources | No |
| Mamdani | Yes | End NYPD assistance to ICE; restore 200 immigration lawyers | Won't defund; wants full agency staffing across the board | Fill paraprofessional vacancies; fix 10,000-position shortage | No |
| Myrie | Yes | $1B state/city rainy-day fund to cover cuts | Deputy Mayor for Quality of Life; data-driven coordination | Supports it; wants pension reform (Tier 6) to retain teachers | No |
| Stringer | Yes | Avoid Twitter wars; sue when necessary | 34-year low; rebuild ranks | Voted for it in Albany; add mental-health staff post-COVID | No |
| Tilson | No | Strategic restraint; federal funds are 10% of budget | Consistent consequences; rebuild ranks | Opposes it ($1.6B/yr); prefers high-impact teaching methods | No |
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.
Published post: 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:
| Candidate | Unit Target | Timeframe | Rate (units/yr) | vs. 2024 Actual | Key Strength |
|---|---|---|---|---|---|
| Adrienne Adams | 500,000 | 10 years | 50,000/yr | 1.5x | Led City of Yes zoning reform; unlocked 120,000 homes as Speaker; secured $8B in affordable housing funding |
| Andrew Cuomo | 500,000 | 10 years | 50,000/yr | 1.5x | Implemented $20B five-year housing plan as governor; HUD Secretary (1997-2001); direct development track record |
| Brad Lander | 500,000 | 10 years | 50,000/yr | 1.5x | Spearheaded 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 Mamdani | 200,000 | 10 years | 20,000/yr | 0.6x | $100B capital commitment; $70B in new municipal bonds; 100% affordable fast-tracking; community land trust focus |
| Zellnor Myrie | 700,000 | 10 years | 70,000/yr | 2.1x | 95,000 mixed-income NYCHA units plus preserve 150,000; authored Housing Stability and Tenant Protection Act |
| Scott Stringer | Not specified | Not specified | (none) | (none) | 1,000+ vacant city-owned lots identified (50,000+ potential units); NYCHA audit expertise; ROOF family-sized unit initiative |
| Whitney Tilson | 800,000 | 15 years | 53,000/yr | 1.6x | M1/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.
Published post: reflections on the win
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
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
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.