open-navigator / web_docs /docs /deployment /events-bronze-migration.md
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
|
Raw
History Blame Contribute Delete
13 kB
# Bronze Migration for Events Data
## Overview
This migration creates bronze tables for `event` and `events_text_search` (renamed to `events_text_ai`), following the bronze β†’ staging β†’ marts dbt architecture pattern.
## Architecture
```
Bronze Layer (open_navigator_bronze)
↓
Staging Layer (dbt views with cleaning/validation)
↓
Marts Layer (dbt tables - production-ready)
```
## Files Created
### Database Migrations
1. **003_create_bronze_event.sql**
- Creates `bronze_event` table in `open_navigator_bronze` database
- Stores raw meeting events from LocalView, YouTube, Legistar, etc.
- Includes all fields from current production `event`
- Tracks data source (`source`, `datasource_id` columns)
2. **004_create_bronze_event_youtube_transcript.sql**
- Creates `bronze_event_youtube_transcript` table in `open_navigator_bronze` database
- Stores video transcripts and AI-extracted text
- Replaces production `events_text_search` table
- Includes quality flags and AI model tracking
### dbt Staging Models
3. **stg_bronze_event.sql**
- Staging view applying basic cleaning to bronze events
- Normalizes state codes (UPPER), cities (INITCAP)
- Adds quality flags: `missing_title`, `missing_date`, `missing_state`, `video_missing_channel`
- Filters out events without title or date
4. **stg_bronze_event_youtube_transcript.sql**
- Staging view cleaning video transcripts
- Calculates `word_count` and `transcript_length`
- Adds quality flags: `missing_transcript`, `very_short_transcript`, `missing_segments`
- Filters out transcripts <100 characters
### dbt Mart Models (Production)
5. **event.sql**
- Production-ready events table (replaces current `event`)
- **Deduplicates by video_url** (keeps most recent)
- Applies quality filters
- Compatible with current API schema
6. **events_text_search.sql**
- Production-ready transcripts table (replaces current `events_text_search`)
- Joins to `event` to get `event_id`
- **Deduplicates by video_id** (keeps highest quality)
- Quality scoring: prefers manual transcripts, then by word count
### Configuration Files
7. **dbt_project/models/staging/_staging.yml**
- Added `bronze_event` source definition
- Added `bronze_event_youtube_transcript` source definition
- Added `stg_bronze_event` model documentation
- Added `stg_bronze_event_youtube_transcript` model documentation
8. **dbt_project/models/marts/_marts.yml**
- Added `event` model documentation
- Added `events_text_search` model documentation
## Migration Steps
### Step 1: Create Bronze Tables
```bash
# Create bronze_event table
psql -h localhost -p 5433 -U postgres -d open_navigator_bronze \
-f packages/hosting/scripts/neon/migrations/003_create_bronze_event.sql
# Create bronze_event_youtube_transcript table
psql -h localhost -p 5433 -U postgres -d open_navigator_bronze \
-f packages/hosting/scripts/neon/migrations/004_create_bronze_event_youtube_transcript.sql
```
### Step 2: Import Foreign Tables
```bash
# In open_navigator database, import bronze tables via FDW
psql -h localhost -p 5433 -U postgres -d open_navigator -c "
IMPORT FOREIGN SCHEMA public
LIMIT TO (bronze_event, bronze_event_youtube_transcript)
FROM SERVER bronze_server INTO bronze;
"
```
### Step 3: Load Sample Data (Testing)
```bash
# Copy 100 sample events from production to bronze for testing
psql -h localhost -p 5433 -U postgres -d open_navigator_bronze -c "
INSERT INTO bronze_event (
title, description, event_date, event_time,
jurisdiction_id, jurisdiction_name, jurisdiction_type,
state_code, state, city, location, meeting_type, status,
agenda_url, minutes_url, video_url,
channel_id, channel_url, channel_type,
view_count, duration_minutes, like_count, language,
source, datasource_id
)
SELECT
event_title, event_description, event_date, event_time,
jurisdiction_id, jurisdiction_name, jurisdiction_type,
state_code, state, city, location, meeting_type, status,
agenda_url, minutes_url, video_url,
channel_id, channel_url, channel_type,
view_count, duration_minutes, like_count, language,
COALESCE(source, 'unknown') AS source,
CAST(event_id AS VARCHAR) AS datasource_id
FROM open_navigator.public.event
ORDER BY event_date DESC
LIMIT 100;
"
# Copy sample transcripts
psql -h localhost -p 5433 -U postgres -d open_navigator_bronze -c "
INSERT INTO bronze_event_youtube_transcript (
event_id, video_id, raw_text, segments,
language, is_auto_generated, transcript_source,
has_transcript, created_at
)
SELECT
event_id, video_id, raw_text, segments,
language, is_auto_generated, transcript_source,
TRUE AS has_transcript, created_at
FROM open_navigator.public.events_text_search
LIMIT 100;
"
```
### Step 4: Run dbt Models
```bash
cd dbt_project
# Test staging models
dbt run --select stg_bronze_event stg_bronze_event_youtube_transcript
# Build production marts
dbt run --select event events_text_search
# Run tests
dbt test --select event events_text_search
```
### Step 5: Verify Results
```sql
-- Check events count
SELECT
'bronze_event' AS table_name,
COUNT(*)
FROM bronze.bronze_event
UNION ALL
SELECT
'event (dbt mart)',
COUNT(*)
FROM event;
-- Check transcripts count
SELECT
'bronze_event_youtube_transcript' AS table_name,
COUNT(*)
FROM bronze.bronze_event_youtube_transcript
UNION ALL
SELECT
'events_text_search (dbt mart)',
COUNT(*)
FROM events_text_search;
-- Verify deduplication worked
SELECT
COUNT(*) AS total_bronze_events,
COUNT(DISTINCT video_url) AS unique_video_urls
FROM bronze.bronze_event
WHERE video_url IS NOT NULL;
```
## Data Flow Diagram
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ DATA SOURCES β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ LocalView β”‚ YouTube β”‚ Legistar β”‚ Other (Granicus, etc.) β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚ β”‚
↓ ↓ ↓ ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ BRONZE LAYER (open_navigator_bronze) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ bronze_event β”‚ bronze_event_youtube_transcript β”‚
β”‚ - Raw events from all β”‚ - Raw transcripts β”‚
β”‚ sources β”‚ - AI extraction metadata β”‚
β”‚ - May contain duplicates β”‚ - Quality flags β”‚
β”‚ - Tracks source system β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
↓ (FDW) ↓ (FDW)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STAGING LAYER (dbt views - open_navigator) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ stg_bronze_event β”‚ stg_bronze_event_youtube_transcript β”‚
β”‚ - Clean & normalize β”‚ - Calculate word count β”‚
β”‚ - Quality flags β”‚ - Filter <100 chars β”‚
β”‚ - No deduplication β”‚ - Quality scoring β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
↓ ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ MARTS LAYER (dbt tables - open_navigator) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ event β”‚ events_text_search β”‚
β”‚ - Deduplicate by video_url β”‚ - Join to get event_id β”‚
β”‚ - Production-ready β”‚ - Deduplicate by video_id β”‚
β”‚ - API-compatible schema β”‚ - Production-ready β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
↓ ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ API & FRONTEND β”‚
β”‚ (api/routes/search_postgres.py) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
## Quality Improvements
### Events Deduplication
**Before:** Direct loading to production could create duplicates
**After:**
- Bronze layer tracks all raw events
- Staging adds quality flags
- Marts deduplicate by `video_url` (keeps most recent)
### Transcript Quality Scoring
**Before:** No quality ranking for multiple transcripts
**After:**
- Quality score based on: manual > auto-generated, word count
- Keeps only highest quality transcript per video
- Filters transcripts <100 characters
### Data Lineage
**Before:** Unclear where events came from
**After:**
- `source` field tracks origin (localview, youtube, legistar)
- `datasource_id` stores original system ID
- Full history in bronze layer
## Updating Data Loading Scripts
### Current Scripts to Update
1. **packages/scrapers/src/scrapers/youtube/load_youtube_events_to_postgres.py**
- Change: Insert to `bronze_event` instead of `event`
- Change: Insert to `bronze_event_youtube_transcript` instead of `events_text_search`
2. **scripts/datasources/localview/load_to_postgres.py**
- Change: Insert to `bronze_event` instead of `event`
3. **Any other scripts inserting to event**
- Search: `grep -r "INSERT INTO event" scripts/`
- Update to insert to `bronze_event`
### After Updating Scripts
```bash
# Run updated loader script
python packages/scrapers/src/scrapers/youtube/load_youtube_events_to_postgres.py --states AL,MA
# Run dbt to update production tables
cd dbt_project
dbt run --select event events_text_search
# Production tables are now up to date!
```
## Benefits
βœ… **Version Control** - All transformations in SQL tracked by git
βœ… **Testable** - dbt tests ensure data quality
βœ… **Deduplication** - Automatic deduplication in marts layer
βœ… **Quality Filters** - Consistent quality rules applied
βœ… **Data Lineage** - Clear path from source to production
βœ… **Rollback-able** - Can rebuild from bronze at any time
## Next Steps
1. **Update loading scripts** - Change insert targets to bronze tables
2. **Test full pipeline** - Load β†’ dbt run β†’ API query
3. **Schedule dbt runs** - Add to cron/Airflow for daily updates
4. **Monitor quality** - Review dbt test results regularly
5. **Backfill bronze** - Load historical data from production to bronze
## Questions?
See also:
- [YouTube Channels Bronze Migration](youtube-channels-bronze-migration.md)
- [dbt Quick Reference](../dbt/quick-reference.md)