abigailkeegan's picture
Deploy ReelShield to Hugging Face Space
8af1084
|
Raw
History Blame Contribute Delete
20.6 kB
# πŸ“Š Data Engineering - ReelShield
## Overview
ReelShield uses **SQLite** for both local/Docker development and the deployed Hugging Face Space. All schema decisions are documented below.
---
## Entity-Relationship Diagram
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ movies β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ tmdb_id INTEGER PK β”‚
β”‚ title TEXT NOT NULL β”‚
β”‚ year TEXT β”‚
β”‚ imdb_id TEXT β”‚
β”‚ runtime_min INTEGER β”‚
β”‚ metadata_json TEXT (full blob) β”‚
β”‚ last_updated TEXT (ISO 8601) β”‚
β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ 1 β”‚ 1 β”‚ 1
β”‚ β”‚ β”‚
β”‚ 1 β”‚ 1 β”‚ 1
β”Œβ”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚content_warn β”‚ β”‚movie_embeddngβ”‚ β”‚ movie_clusters β”‚
β”‚-ings β”‚ β”‚ β”‚ β”‚ β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ tmdb_id PK β”‚ β”‚ tmdb_id PK β”‚ β”‚ tmdb_id PK β”‚
β”‚ warnings_j β”‚ β”‚ embedding β”‚ β”‚ cluster_id β”‚
β”‚ last_updatedβ”‚ β”‚ model_name β”‚ β”‚ distance_to_cent β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ text_hash β”‚ β”‚ model_version β”‚
β”‚ created_at β”‚ β”‚ created_at β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
**Relationships:** Each movie has at most one cached warning set, one MiniLM embedding, and one K-Means cluster assignment (all 1:1). Warnings are regenerated only when explicitly refreshed; embeddings are written by `backend/embed_all_movies.py` (a one-pass batch over the cached films using the pretrained `all-MiniLM-L6-v2` model); cluster assignments are written by `backend/train_cluster_model.py` (the K-Means trainer, the only classic-ML model on this side). Both self-heal on the HF Space at container cold start when missing.
---
## Schema Documentation
### Table: `movies`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `tmdb_id` | INTEGER | PRIMARY KEY | TMDB's unique film identifier |
| `title` | TEXT | NOT NULL | Film title as returned by TMDB |
| `year` | TEXT | | 4-digit release year |
| `imdb_id` | TEXT | | IMDb identifier (for future cross-referencing) |
| `runtime_min` | INTEGER | | Runtime in minutes |
| `metadata_json` | TEXT | | Full JSON blob of TMDB + computed metadata |
| `last_updated` | TEXT | | ISO 8601 timestamp of last cache update |
**Indexes:**
- `tmdb_id` (PRIMARY KEY - automatically indexed)
- Recommended future index: `title` for text search
**Why JSON blob?** The metadata structure evolves as we add new data sources (TMDB keywords, OMDb ratings, etc.). Storing as JSON avoids schema migrations every time a new field is added, while still allowing the app to access structured data at runtime.
---
### Table: `content_warnings`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `tmdb_id` | INTEGER | PRIMARY KEY, FK | References `movies.tmdb_id` |
| `warnings_json` | TEXT | | Full JSON blob of Gemini-generated warnings |
| `last_updated` | TEXT | | ISO 8601 timestamp |
**Warning JSON structure:**
```json
{
"disclaimer": "Gemini knowledge-based",
"spoiler_free": {
"violence_gore": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"self_harm_suicide": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"miscarriage_pregnancy_loss":{"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"sexual_content_nudity": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"animal_abuse": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"substances": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"language": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"horror_intensity": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"flashing_lights": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""}
},
"spoiler_full": {
"violence_gore": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"self_harm_suicide": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"miscarriage_pregnancy_loss":{"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"sexual_content_nudity": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"animal_abuse": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"substances": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"language": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"horror_intensity": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""},
"flashing_lights": {"severity": 0-3, "confidence": 0.0-1.0, "notes": ""}
}
}
```
**Severity scale:** 0 = absent, 1 = mild/brief, 2 = moderate, 3 = severe/graphic
**Confidence scale:** 1.0 = confirmed by AI knowledge, <1.0 = estimated (obscure film)
**Why two blocks?** `spoiler_free` and `spoiler_full` carry the same `severity` and `confidence` values for each category β€” only the `notes` text differs. The spoiler-mode toggle in the UI picks which `notes` to render; the underlying numbers describe the film itself, not the description style. Older cached rows may only contain `spoiler_free`; the frontend falls back to it when `spoiler_full` is absent.
---
### Table: `users`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal user id |
| `username` | TEXT | UNIQUE, NOT NULL | Login name |
| `password_hash` | TEXT | NOT NULL | werkzeug pbkdf2(sha256) hash |
| `created_at` | TEXT | NOT NULL | ISO 8601 timestamp |
**Indexes:** `idx_users_username` on `username`.
---
### Table: `reviews`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Review id |
| `tmdb_id` | INTEGER | NOT NULL, FK β†’ `movies.tmdb_id` | Film being reviewed |
| `user_id` | INTEGER | NOT NULL, FK β†’ `users.id` | Author |
| `username` | TEXT | NOT NULL | Denormalized for fast display |
| `rating` | INTEGER | | 1–5 stars; nullable for text-only reviews |
| `review_text` | TEXT | NOT NULL | Body |
| `created_at` | TEXT | NOT NULL | ISO 8601 timestamp |
**Indexes:** `idx_reviews_tmdb_id`, `idx_reviews_user_id`.
---
### Table: `watchlist`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Row id |
| `user_id` | INTEGER | NOT NULL, FK β†’ `users.id` | Owner |
| `tmdb_id` | INTEGER | NOT NULL | Film |
| `added_at` | TEXT | NOT NULL | ISO 8601 timestamp |
**Constraints:** `UNIQUE(user_id, tmdb_id)` prevents duplicate entries.
**Indexes:** `idx_watchlist_user_id`.
---
### Table: `user_sensitivities`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `user_id` | INTEGER | PRIMARY KEY, FK β†’ `users.id` | One row per user |
| `sensitivities_json` | TEXT | NOT NULL | JSON array of warning-category strings the user always wants to avoid |
| `updated_at` | TEXT | NOT NULL | ISO 8601 timestamp |
---
### Table: `movie_loads`
Instrumentation table. One row per `/api/load_movie` call.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `load_id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Load id |
| `tmdb_id` | INTEGER | NOT NULL | Film loaded |
| `title`, `year`, `genre`, `mpaa_rating` | TEXT | | Snapshot of movie metadata |
| `was_cached` | INTEGER | NOT NULL DEFAULT 0 | 0/1 boolean - cache hit |
| `load_time_ms` | INTEGER | | End-to-end load latency |
| `warnings_generated` | TEXT | | JSON array of triggered category names |
| `confidence_scores` | TEXT | | JSON map of category β†’ confidence |
| `spoiler_mode_on` | INTEGER | NOT NULL DEFAULT 0 | 0/1 boolean |
| `timestamp` | TEXT | NOT NULL | ISO 8601 timestamp |
**Indexes:** `idx_movie_loads_tmdb_id`, `idx_movie_loads_timestamp`.
---
### Table: `warning_analytics`
One row per `(load_id, category)`. Used to compute per-category trigger rates and average confidence over time.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `warning_id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Row id |
| `load_id` | INTEGER | FK β†’ `movie_loads.load_id` | The load this row belongs to |
| `tmdb_id` | INTEGER | NOT NULL | Film |
| `category` | TEXT | NOT NULL | Warning category name |
| `was_triggered` | INTEGER | NOT NULL DEFAULT 0 | 0/1 boolean |
| `confidence_score` | REAL | | 0.0–1.0 |
| `severity_level` | INTEGER | | 0–3 |
| `user_flagged_inaccurate` | INTEGER | NOT NULL DEFAULT 0 | Reserved for a future "flag this warning" UI |
| `flag_reason` | TEXT | | Reserved for the same |
| `timestamp` | TEXT | NOT NULL | ISO 8601 timestamp |
**Indexes:** `idx_warning_analytics_tmdb_id`, `idx_warning_analytics_category`.
---
### Table: `movie_embeddings`
Sentence-transformer (`all-MiniLM-L6-v2`) embeddings of each film's title + overview + keywords. Used to re-rank recommendations and match free-text mood queries on the Discover page.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `tmdb_id` | INTEGER | PRIMARY KEY, FK β†’ `movies.tmdb_id` (ON DELETE CASCADE) | One row per film |
| `embedding` | BLOB | NOT NULL | 384-dim float32 vector packed as bytes |
| `model_name` | TEXT | NOT NULL | Embedding model identifier (`all-MiniLM-L6-v2`) |
| `text_hash` | TEXT | NOT NULL | SHA1 of the source text β€” lets the pipeline re-embed only when the source string changes |
| `created_at` | TEXT | NOT NULL | ISO 8601 timestamp |
Populated by `backend/embed_all_movies.py`; consumed by `backend/embeddings.py:cosine_top_k`.
---
### Table: `movie_clusters`
K-Means cluster assignment per film. Used by the "Content twins" UI section (`/api/content_twins/<tmdb_id>`) and the offline cluster-archetype analysis.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `tmdb_id` | INTEGER | PRIMARY KEY, FK β†’ `movies.tmdb_id` (ON DELETE CASCADE) | One row per film |
| `cluster_id` | INTEGER | NOT NULL | 0..K-1; mapping to a human-readable label lives in the `.pkl` (`cluster_names` dict) |
| `distance_to_centroid` | REAL | | Euclidean distance to the assigned centroid in 9-dim warning space |
| `model_version` | TEXT | NOT NULL | E.g. `kmeans_k5_seed42` β€” bumps when the model is retrained |
| `created_at` | TEXT | NOT NULL | ISO 8601 timestamp |
Populated by `backend/train_cluster_model.py`. On the HF Space, the table is also self-healed at cold start (see `_populate_movie_clusters_if_empty()` in `backend/app.py`) so the feature works even when the hydrated `movie_cache.db` pre-dates the K-Means commit.
---
### Table: `warning_feedback`
Thumbs up / thumbs down feedback the user gives on specific warning categories and chat replies.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Row id |
| `user_id` | INTEGER | | Nullable - anonymous sessions may also leave feedback |
| `movie_id` | INTEGER | NOT NULL | Film |
| `category` | TEXT | | Warning category, or NULL for chat feedback |
| `feedback_type` | TEXT | NOT NULL | `'warning'` or `'chat'` |
| `rating` | TEXT | NOT NULL | `'up'` or `'down'` |
| `chat_message_text` | TEXT | | The chat reply being rated, if applicable |
| `created_at` | TEXT | NOT NULL | ISO 8601 timestamp |
**Indexes:** `idx_warning_feedback_movie_id`, `idx_warning_feedback_user_id`.
---
## Data Sources
| Source | Type | What We Fetch | Rate Limit |
|--------|------|---------------|------------|
| TMDB API | REST | Title, year, genres, runtime, rating, keywords, poster, overview | 50 req/sec |
| Gemini 2.5 Flash | AI API | Content warnings (9 categories) | 15 req/min (free), higher on paid |
| HF Dataset (`abigailkeegan/reelshield-cache`) | File pull | Pre-warmed `movie_cache.db` + classic-ML `.pkl` artifacts on cold start | N/A |
| `all-MiniLM-L6-v2` (sentence-transformers) | Local model | 384-dim embedding per film | N/A β€” runs in-process |
| `cluster_model.pkl` (K-Means) | Local model | Cluster assignments + content-twins queries | N/A β€” runs in-process |
| `mpa_classifier.pkl` (LogisticRegression) | Local model | Predicted MPA bucket for the disagreement badge | N/A β€” runs in-process |
| SQLite | Local DB | Cached movies, warnings, embeddings, cluster assignments | N/A |
---
## Data Flow
```
[Container cold start]
β”‚
β–Ό
Hydrate /data/movie_cache.db from HF Dataset (if missing/empty)
Hydrate /data/{cluster,mpa}_classifier.pkl from HF Dataset
Self-heal movie_clusters table if empty (uses cluster_model.pkl)
β”‚
β–Ό
[Request: User searches "Titanic"]
β”‚
β–Ό
TMDB /search/movie
β”‚
β–Ό
User selects result
β”‚
β–Ό
Check SQLite cache ──── HIT ──▢ Return cached warnings + run MPA classifier
β”‚ on cached warning vector β†’ optional badge
MISS ──▢ Return to frontend
β”‚
β–Ό
TMDB /movie/{id} (title, genres, runtime, rating)
TMDB /movie/{id}/keywords (community content tags)
TMDB /movie/{id}/release_dates (MPA certification)
β”‚
β–Ό
Gemini 2.5 Flash
Input: title + year + rating + genres + overview + keywords
Output: JSON with 9 warning categories
β”‚
β–Ό
Save to SQLite cache; run MPA classifier on the new warning vector
β”‚
β–Ό
Return to frontend (warnings + optional mpa_prediction)
```
---
## Backup & Recovery Strategy
**SQLite/Docker (current):**
- SQLite file is mounted at `/data/movie_cache.db` inside the container via the `./data` bind mount in `docker-compose.yml`.
- Backup: `docker run --rm -v movie-warnings_movie_data:/data -v $(pwd):/backup alpine tar czf /backup/db-backup.tar.gz /data`
- On Hugging Face Spaces, the SQLite file lives on the Space's filesystem; production-grade durability is delegated to the planned RDS migration described below.
**Planned production (RDS):**
- Automated daily snapshots via AWS RDS
- Point-in-time recovery enabled
- Cross-region snapshot copy for disaster recovery
---
## Production Architecture (Planned: AWS RDS)
The current Hugging Face Space runs SQLite for demo simplicity, but the schema is designed to migrate to **PostgreSQL on AWS RDS** for production scale. The table layout above is portable as-is - every column type maps cleanly to PostgreSQL (INTEGER β†’ BIGINT/INTEGER, TEXT β†’ TEXT, REAL β†’ DOUBLE PRECISION) and the JSON blobs become `JSONB` for indexable queries.
### Architecture diagram
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Hugging Face Space / EC2 β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Flask + Gunicorn (backend/app.py) β”‚ β”‚
β”‚ β”‚ ─ /api/search, /api/load_movie, /api/discover, … β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ psycopg2 (5432, TLS in transit)
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ AWS RDS - PostgreSQL 15 β”‚
β”‚ ─ db.t3.micro (dev/MVP) β”‚
β”‚ ─ Multi-AZ (prod) β”‚
β”‚ ─ Encryption at rest (KMS) β”‚
β”‚ ─ Automated 7-day backups β”‚
β”‚ ─ Cross-region snapshot copy β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–²
β”‚ /find/{imdb_id}, /movie/{id}, /search/movie
β”‚ generate_content()
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ TMDB API β”‚ β”‚ OMDb API β”‚ β”‚ Gemini 2.5 Flash β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
In production, the .pkl model artifacts would migrate from the public
HF Dataset to a private S3 bucket alongside the RDS schema, with model
versions referenced from a config table so retrains don't require code
pushes.
```
### Migration plan (SQLite β†’ PostgreSQL RDS)
**Step 1 - Provision RDS:**
| Setting | Value | Reason |
|---------|-------|--------|
| Engine | PostgreSQL 15 | First-class JSONB, mature |
| Instance | db.t3.micro | Sufficient for demo/MVP traffic |
| Storage | 20 GB gp2 | Generous for movie cache |
| Multi-AZ | No (dev), Yes (prod) | Cost vs availability tradeoff |
| Backup | 7-day retention | Recovery window |
| Encryption | At rest (KMS) + in transit (TLS) | API key data in JSON blobs |
**Step 2 - Add the driver to `requirements.txt`:**
```
psycopg2-binary==2.9.9
```
**Step 3 - Wire `DATABASE_URL` into `docker-compose.yml`:**
```yaml
services:
app:
environment:
DATABASE_URL: postgresql://user:pass@your-rds-endpoint:5432/reelshield
```
**Step 4 - Swap the connection logic in `backend/app.py`:**
```python
import psycopg2
DATABASE_URL = os.environ.get("DATABASE_URL")
conn = psycopg2.connect(DATABASE_URL)
```
**Step 5 - Run the migration scripts** (`001_initial_schema.sql`, `002_users_and_social.sql`, `003_movie_embeddings.sql`, `004_movie_clusters.sql`) against the new database. The DDL is SQLite-compatible but uses only standard SQL features, so it runs cleanly on PostgreSQL after replacing `INTEGER PRIMARY KEY AUTOINCREMENT` with `BIGSERIAL PRIMARY KEY`, changing the JSON `TEXT` columns to `JSONB`, and swapping the `BLOB` column on `movie_embeddings` to `BYTEA`.
---
## Query Optimization
**Current queries and their performance:**
```sql
-- Lookup by tmdb_id (primary key) - O(1), instant
SELECT metadata_json FROM movies WHERE tmdb_id = ?;
SELECT warnings_json FROM content_warnings WHERE tmdb_id = ?;
-- Insert/replace - O(log n) on primary key index
INSERT OR REPLACE INTO movies VALUES (?,?,?,?,?,?,?);
```
**Future optimization for scale:**
```sql
-- Add index on title for text search
CREATE INDEX idx_movies_title ON movies(title);
-- Add index on last_updated to find stale cache entries
CREATE INDEX idx_warnings_updated ON content_warnings(last_updated);
```
---
## Analytics Examples
A worked example of running analytics against this schema β€” average warning severity per category per decade, plus trend findings β€” is in [`decade-analysis.md`](decade-analysis.md). It includes the `json_extract`-based SQL used to compute the per-category averages from `content_warnings.warnings_json`.