# Project export: Tribune

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: GitHub for democracy. Every policy change cited to a voice.
- Devpost: https://devpost.com/software/tribune
- GitHub: https://github.com/arihantjain4/Tribune
- Demo: http://trytribune.com/
- Video: https://www.youtube.com/embed/CNRvU4jK34c?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner ([Anthropic] Human Flourishing Track (1st Place: 4 tungsten cubes 2nd Place: 1 year of Claude Pro 3rd Place: 6 months of Claude Pro))
- Team: 2 GitHub contributor(s) — Arihant Jain (35 commits), Tyler Bordeaux (12 commits)

## Devpost submission (written by the team)

### Inspiration

In Palo Alto, the city council meets twice a month to vote on policies that affect parking, housing, zoning, and bike lanes. These decisions shape where you can live, how you get to work, and what your neighborhood looks like. But fewer than 2% of residents ever attend a council meeting. The other 98% are busy: working, caregiving, commuting. Their opinions exist, but nobody collects them. We asked: what if instead of waiting for residents to show up, you went to them? What if an AI agent could read the agenda overnight, figure out who's affected, call those people directly, and turn their feedback into policy revisions, with every line cited to a real voice? That's Tribune, GitHub for democracy. We turn phone calls into policy diffs.

### What it does

Tribune automates the entire civic feedback loop in three steps: Watch: Tribune scrapes city council agendas, municipal codes, and government PDFs overnight. It extracts every policy proposal, identifies affected neighborhoods, and geo-tags them. For our demo, we processed real Palo Alto city council documents and extracted ~200 actionable policies across 9 categories (housing, transportation, zoning, etc.). Listen: Instead of hoping residents attend a 7pm meeting, Tribune calls them. An AI phone agent conducts natural interviews with residents on affected streets, asking about concerns, following up in real time, and transcribing everything. Each quote is tagged with sentiment, topic, and linked to the specific policy clause it references. Draft: Tribune generates policy revisions as diffs, red lines for removed text, green lines for additions: where every single change cites the resident interviews that motivated it. Policymakers can approve the revision, request changes, or send it to committee. Like a pull request, but for legislation. The platform also includes a Research Desk: an AI agent powered by Claude that can answer natural language questions about the entire policy corpus. Ask "How do renters feel about the parking changes?" and watch it search across policies, resident quotes, and proposed revisions in real time, streaming its reasoning step by step.

### How we built it

Tribune is a full-stack platform with 5 AI providers, 3 Elasticsearch indices, and a real-time voice calling pipeline. Data Pipeline (4 stages) Collect: PrimeGov API + web scrapers discover documents from Palo Alto's city council site Download: Async HTTP fetches PDFs, deduplicates by content hash Extract: Google Gemini 2.0 Flash reads PDFs and outputs structured policy JSON (title, clause, geography, category) Classify: GPT-4o-mini scores each policy on actionability and resident impact (1-5) Search (Elasticsearch + JINA) 3 indices: policies, resident quotes, and proposed diffs Hybrid search pipeline: BM25 text search + kNN vector search (JINA embeddings, 1024-dim) fused via Reciprocal Rank Fusion (k=60), then reranked with JINA Reranker v3 Custom civic synonym analyzer (ADU = granny flat, BMR = affordable housing, etc.) Semantic text fields with auto-chunking for conceptual search Elasticsearch Agent Builder integration with 7 ES|QL tools callable via Agent-to-Agent protocol AI Research Agent (Claude Sonnet 4.5) 8 tools: search_policies, search_quotes, search_diffs, analyze_sentiment, find_related_policies, semantic_search, ask_agent_builder, trending_topics Up to 4 reasoning iterations per query Multi-turn conversation with history, follow-up questions reference prior findings Streams every step (thinking → tool call → result → answer) to the browser via SSE Voice Calling (OpenAI Realtime + Twilio) Twilio WebSocket connects call audio to OpenAI's Realtime API bidirectionally AI interviewer greets, introduces the policy, asks for feedback, follows up naturally, thanks the resident Live transcript streams to the monitoring UI via WebSocket Human in the loop: Policymakers can send hidden "directions" mid-call to steer the conversation On call end: auto-analysis → extract quotes → generate policy diff — full loop closes automatically Diff Generation (Claude Sonnet 4.5) Takes original clause + all interview data (sentiment, concerns, demographics, quotes) Constraint: must copy original text exactly and only insert or delete 1-2 short clauses (<15 words each) Every insertion must trace to specific constituent concerns Output includes revised clause, commit message with statistics, and citation chain Stack Frontend: Next.js 16, React 19, TypeScript, Tailwind v4, Framer Motion, Mapbox GL, shadcn/ui Backend: FastAPI (Python), two servers: port 8000 (REST API) and port 8001 (voice server) Database: SQLite (WAL mode) with 12 tables tracking the full chain: raw documents → policies → interviews → quotes → analysis → diffs Config: City configuration is YAML-based. Adding a new city = adding one file.

### Challenges we ran into

PDF extraction at scale. Government PDFs are hostile, with inconsistent formatting, scanned images, tables within tables. We iterated heavily on our Gemini extraction prompts to reliably pull structured policy data from 200+ documents with different layouts. Citation integrity. Our core design principle is that every policy revision must trace back to a real resident voice. Building the citation chain (diff → interview IDs → quotes → resident) across 12 database tables while keeping it queryable and renderable in the UI was our hardest architectural challenge. Voice call reliability. The Twilio → OpenAI Realtime bidirectional audio pipeline is latency-sensitive. Getting natural-feeling conversations with sub-second response times required careful buffer management and prompt engineering to keep the AI interviewer concise and interruptible. Hybrid search tuning. Balancing BM25 keyword relevance against semantic vector similarity via RRF fusion required extensive testing. Civic language is domain-specific ("ADU" vs "granny flat"), so we built a custom synonym analyzer to bridge how residents talk and how policies are written.

### Accomplishments we're proud of

Processed ~200 real Palo Alto city council policies from actual government documents: this is not mock data Built a full AI phone interview pipeline that closes the loop automatically: call → transcript → analysis → policy revision Created a multi-turn research agent with 8 Elasticsearch-backed tools that streams its reasoning in real time Designed a citation-first architecture where zero policy changes exist without traced resident feedback Shipped 7 polished frontend pages with an editorial design system in 36 hours Integrated 5 AI providers (Gemini, OpenAI, Anthropic Claude, JINA, Elasticsearch) into a coherent pipeline

### What we learned

Hybrid search (BM25 + semantic vectors + reranking) dramatically outperforms either approach alone for domain-specific civic text The Elasticsearch Agent Builder A2A protocol enables powerful delegation patterns: our Claude agent can offload complex ES|QL queries to Kibana agents Real-time voice AI has crossed the threshold where natural phone interviews are possible: six months ago, the latency would have made this unusable Government data is messy, inconsistent, and poorly structured. But it's public. An automated pipeline can democratize access to information that currently requires attending a meeting in person

### What's next

We're just getting started. There are 19,500 incorporated municipalities in the US, each holding council meetings that almost no one attends. Tribune starts with Palo Alto but is designed to scale to any city via YAML configuration. Next: expanding to more Bay Area cities, building relationships with city clerks and council staff, and exploring how this platform could serve state legislatures and federal public comment periods.

## README (from the GitHub repository)

## Inspiration

In Palo Alto, the city council meets twice a month to vote on policies that affect parking, housing, zoning, and bike lanes. These decisions shape where you can live, how you get to work, and what your neighborhood looks like. But fewer than 2% of residents ever attend a council meeting. The other 98% are busy: working, caregiving, commuting. Their opinions exist, but nobody collects them.

We asked: what if instead of waiting for residents to show up, you went to them? What if an AI agent could read the agenda overnight, figure out who's affected, call those people directly, and turn their feedback into policy revisions, with every line cited to a real voice?

That's Tribune, GitHub for democracy. We turn phone calls into policy diffs.

![Tribune diff view](https://i.ibb.co/d4vVbhXd/Diff.jpg)

## What it does

Tribune automates the entire civic feedback loop in three steps:

**Watch**:    Tribune scrapes city council agendas, municipal codes, and government PDFs overnight. It extracts every policy proposal, identifies affected neighborhoods, and geo-tags them. For our demo, we processed real Palo Alto city council documents and extracted ~200 actionable policies across 9 categories (housing, transportation, zoning, etc.).

**Listen**:    Instead of hoping residents attend a 7pm meeting, Tribune calls them. An AI phone agent conducts natural interviews with residents on affected streets, asking about concerns, following up in real time, and transcribing everything. Each quote is tagged with sentiment, topic, and linked to the specific policy clause it references.

**Draft**:    Tribune generates policy revisions as diffs, red lines for removed text, green lines for additions: where every single change cites the resident interviews that motivated it. Policymakers can approve the revision, request changes, or send it to committee. Like a pull request, but for legislation.

The platform also includes a **Research Desk**: an AI agent powered by Claude that can answer natural language questions about the entire policy corpus. Ask "How do renters feel about the parking changes?" and watch it search across policies, resident quotes, and proposed revisions in real time, streaming its reasoning step by step.

![Tribune architecture](https://i.ibb.co/GvqJRMRc/Architecture.jpg)

## How we built it

Tribune is a full-stack platform with 5 AI providers, 3 Elasticsearch indices, and a real-time voice calling pipeline.

### Data Pipeline (4 stages)

- **Collect:** PrimeGov API + web scrapers discover documents from Palo Alto's city council site
- **Download:** Async HTTP fetches PDFs, deduplicates by content hash
- **Extract:** Google Gemini 2.0 Flash reads PDFs and outputs structured policy JSON (title, clause, geography, category)
- **Classify:** GPT-4o-mini scores each policy on actionability and resident impact (1-5)

### Search (Elasticsearch + JINA)

- 3 indices: policies, resident quotes, and proposed diffs
- Hybrid search pipeline: BM25 text search + kNN vector search (JINA embeddings, 1024-dim) fused via Reciprocal Rank Fusion (k=60), then reranked with JINA Reranker v3
- Custom civic synonym analyzer (ADU = granny flat, BMR = affordable housing, etc.)
- Semantic text fields with auto-chunking for conceptual search
- Elasticsearch Agent Builder integration with 7 ES|QL tools callable via Agent-to-Agent protocol

### AI Research Agent (Claude Sonnet 4.5)

- 8 tools: search_policies, search_quotes, search_diffs, analyze_sentiment, find_related_policies, semantic_search, ask_agent_builder, trending_topics
- Up to 4 reasoning iterations per query
- Multi-turn conversation with history, follow-up questions reference prior findings
- Streams every step (thinking → tool call → result → answer) to the browser via SSE

### Voice Calling (OpenAI Realtime + Twilio)

- Twilio WebSocket connects call audio to OpenAI's Realtime API bidirectionally
- AI interviewer greets, introduces the policy, asks for feedback, follows up naturally, thanks the resident
- Live transcript streams to the monitoring UI via WebSocket
- Human in the loop: Policymakers can send hidden "directions" mid-call to steer the conversation
- On call end: auto-analysis → extract quotes → generate policy diff — full loop closes automatically

### Diff Generation (Claude Sonnet 4.5)

- Takes original clause + all interview data (sentiment, concerns, demographics, quotes)
- Constraint: must copy original text exactly and only insert or delete 1-2 short clauses (<15 words each)
- Every insertion must trace to specific constituent concerns
- Output includes revised clause, commit message with statistics, and citation chain

### Stack

- **Frontend:** Next.js 16, React 19, TypeScript, Tailwind v4, Framer Motion, Mapbox GL, shadcn/ui
- **Backend:** FastAPI (Python), two servers: port 8000 (REST API) and port 8001 (voice server)
- **Database:** SQLite (WAL mode) with 12 tables tracking the full chain: raw documents → policies → interviews → quotes → analysis → diffs
- **Config:** City configuration is YAML-based. Adding a new city = adding one file.

## Challenges we ran into

**PDF extraction at scale.** Government PDFs are hostile, with inconsistent formatting, scanned images, tables within tables. We iterated heavily on our Gemini extraction prompts to reliably pull structured policy data from 200+ documents with different layouts.

**Citation integrity.** Our core design principle is that every policy revision must trace back to a real resident voice. Building the citation chain (diff → interview IDs → quotes → resident) across 12 database tables while keeping it queryable and renderable in the UI was our hardest architectural challenge.

**Voice call reliability.** The Twilio → OpenAI Realtime bidirectional audio pipeline is latency-sensitive. Getting natural-feeling conversations with sub-second response times required careful buffer management and prompt engineering to keep the AI interviewer concise and interruptible.

**Hybrid search tuning.** Balancing BM25 keyword relevance against semantic vector similarity via RRF fusion required extensive testing. Civic language is domain-specific ("ADU" vs "granny flat"), so we built a custom synonym analyzer to bridge how residents talk and how policies are written.

## Accomplishments that we're proud of

- Processed ~200 real Palo Alto city council policies from actual government documents: this is not mock data
- Built a full AI phone interview pipeline that closes the loop automatically: call → transcript → analysis → policy revision
- Created a multi-turn research agent with 8 Elasticsearch-backed tools that streams its reasoning in real time
- Designed a citation-first architecture where zero policy changes exist without traced resident feedback
- Shipped 7 polished frontend pages with an editorial design system in 36 hours
- Integrated 5 AI providers (Gemini, OpenAI, Anthropic Claude, JINA, Elasticsearch) into a coherent pipeline

## What we learned

- Hybrid search (BM25 + semantic vectors + reranking) dramatically outperforms either approach alone for domain-specific civic text
- The Elasticsearch Agent Builder A2A protocol enables powerful delegation patterns: our Claude agent can offload complex ES|QL queries to Kibana agents
- Real-time voice AI has crossed the threshold where natural phone interviews are possible: six months ago, the latency would have made this unusable
- Government data is messy, inconsistent, and poorly structured. But it's public. An automated pipeline can democratize access to information that currently requires attending a meeting in person

## What's next for Tribune

We're just getting started. There are 19,500 incorporated municipalities in the US, each holding council meetings that almost no one attends. Tribune starts with Palo Alto but is designed to scale to any city via YAML configuration. Next: expanding to more Bay Area cities, building relationships with city clerks and council staff, and explor

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 95 recognized source files, 818 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (115 of 115)

```
.DS_Store
.gitignore
backend/.dockerignore
backend/.DS_Store
backend/.env.example
backend/app/__init__.py
backend/app/__main__.py
backend/app/agent_builder.py
backend/app/agent.py
backend/app/cities/__init__.py
backend/app/cities/nyc.yaml
backend/app/cities/palo_alto.yaml
backend/app/classifier.py
backend/app/collectors/__init__.py
backend/app/collectors/archived/__init__.py
backend/app/collectors/archived/accela.py
backend/app/collectors/archived/ceqa.py
backend/app/collectors/archived/county.py
backend/app/collectors/archived/gis.py
backend/app/collectors/archived/housing.py
backend/app/collectors/archived/news.py
backend/app/collectors/archived/open_data.py
backend/app/collectors/archived/opengov.py
backend/app/collectors/archived/police.py
backend/app/collectors/archived/regional.py
backend/app/collectors/archived/transit.py
backend/app/collectors/archived/video.py
backend/app/collectors/city_website.py
backend/app/collectors/municipal_code.py
backend/app/collectors/primegov.py
backend/app/config.py
backend/app/database.py
backend/app/diff_generator.py
backend/app/downloader.py
backend/app/elastic.py
backend/app/embeddings.py
backend/app/enrich_clauses.py
backend/app/extractor.py
backend/app/main.py
backend/app/models.py
backend/app/pipeline.py
backend/app/seed_interviews.py
backend/app/sync.py
backend/CLAUDE.md
backend/data/tribune.db
backend/data/tribune.db.backup
backend/Dockerfile
backend/fly.toml
backend/package.json
backend/policies.db
backend/requirements.txt
backend/seed_nyc.py
backend/server.py
backend/testing.py
CLAUDE.md
docs/plans/2026-02-14-agentic-search-design.md
docs/plans/2026-02-14-agentic-search-implementation.md
docs/plans/2026-02-14-civic-glass-ui-overhaul-design.md
docs/plans/2026-02-14-civic-glass-ui-overhaul-plan.md
docs/plans/2026-02-14-comprehensive-pa-data-collection-design.md
docs/plans/2026-02-14-interview-seeder-design.md
docs/plans/2026-02-14-pa-collectors-implementation.md
docs/plans/2026-02-14-pdf-viewer.md
docs/plans/2026-02-14-pipeline-design.md
docs/plans/2026-02-14-pipeline-implementation.md
docs/plans/2026-02-14-tribune-design.md
docs/plans/2026-02-14-tribune-implementation.md
frontend/.env.production
frontend/.gitignore
frontend/components.json
frontend/eslint.config.mjs
frontend/next.config.ts
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/src/app/calls/page.tsx
frontend/src/app/campaigns/[id]/page.tsx
frontend/src/app/constituents/page.tsx
frontend/src/app/dashboard/page.tsx
frontend/src/app/globals.css
frontend/src/app/layout.tsx
frontend/src/app/login/page.tsx
frontend/src/app/page.tsx
frontend/src/app/policies/[id]/diff/page.tsx
frontend/src/app/policies/[id]/interviews/page.tsx
frontend/src/app/policies/[id]/page.tsx
frontend/src/app/policies/page.tsx
frontend/src/app/search/page.tsx
frontend/src/components/animated-number.tsx
frontend/src/components/app-shell.tsx
frontend/src/components/pdf-viewer.tsx
frontend/src/components/policy-map.tsx
frontend/src/components/ui/avatar.tsx
frontend/src/components/ui/badge.tsx
frontend/src/components/ui/button.tsx
frontend/src/components/ui/card.tsx
frontend/src/components/ui/dialog.tsx
frontend/src/components/ui/dropdown-menu.tsx
frontend/src/components/ui/input.tsx
frontend/src/components/ui/progress.tsx
frontend/src/components/ui/scroll-area.tsx
frontend/src/components/ui/separator.tsx
frontend/src/components/ui/sheet.tsx
frontend/src/components/ui/skeleton.tsx
frontend/src/components/ui/tabs.tsx
frontend/src/components/ui/textarea.tsx
frontend/src/components/ui/tooltip.tsx
frontend/src/lib/api.ts
frontend/src/lib/city-context.tsx
frontend/src/lib/geo-mapping.ts
frontend/src/lib/types.ts
frontend/src/lib/utils.ts
frontend/tsconfig.json
frontend/vercel.json
README.md
```

### Dependencies

- backend/package.json: react-markdown@^10.1.0
- backend/requirements.txt: anthropic@==0.34.0, elasticsearch@==8.15.0, fastapi@==0.115.0, google-generativeai@==0.8.0, httptools, httpx@==0.27.0, openai@==1.50.0, pandas, pydantic@==2.9.0, pydub, python-dotenv@==1.0.1, python-multipart@==0.0.9, pyyaml@>=6.0, requests, sse-starlette@==2.0.0, twilio, uvicorn[standard]@==0.30.0, websockets@>=14.0
- frontend/package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, class-variance-authority@^0.7.1, clsx@^2.1.1, eslint@^9, eslint-config-next@16.1.6, framer-motion@^12.34.0, lucide-react@^0.564.0, mapbox-gl@^3.18.1, next@16.1.6, radix-ui@^1.4.3, react@19.2.3, react-dom@19.2.3, react-map-gl@^8.1.0, react-markdown@^10.1.0, react-pdf@^10.3.0, recharts@^3.7.0, shadcn@^3.8.4, tailwind-merge@^3.4.0, tailwindcss@^4, tw-animate-css@^1.4.0, typescript@5.9.3

### Recent commits (newest first)

- elasticsearch bug fix prod
- landing page
- Landing
- Readme update
- Readme update
- Readme
- Update link to trytribune.com with HTTPS
- Update link format for trytribune.com
- Rename project and update tagline in README
- ex
- default city
- try
- comp
- rt
- i
- testt
- test
- yuhg
- disable compression
- req

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Voca (Tribune) is a civic engagement platform that discovers municipal policies, captures resident feedback through AI-powered interviews, and generates evidence-based policy revisions. The data flow is: collect policy documents → extract structured policies (Gemini) → classify impact (GPT-4o-mini) → interview residents (OpenAI Realtime + Twilio) → generate policy diffs (Claude) → index for search (Elasticsearch + JINA embeddings).

## Repository Structure

- **`backend/`** — FastAPI Python API + data pipeline
- **`frontend/`** — Next.js 16 React app (TypeScript, Tailwind v4, shadcn/ui)
- **`docs/plans/`** — Design and implementation plan documents

## Commands

### Backend

```bash
cd backend
source .venv_voca/bin/activate
pip install -r requirements.txt

# Run API server
uvicorn app.main:app --reload --port 8000

# Run voice calling server (separate process)
uvicorn server:app --reload --port 8001

# Run data pipeline (collect → download → extract)
python -m app
```

### Frontend

```bash
cd frontend
npm install
npm run dev      # Dev server (Next.js)
npm run build    # Production build
npm run lint     # ESLint
```

## Backend Architecture

Two FastAPI servers:
- **`app/main.py`** — Core REST API (policies, interviews, feedback, diffs, search, agent, dashboard)
- **`server.py`** — Twilio voice calling with OpenAI Realtime API (WebSocket audio streaming)

### Data Pipeline (`app/pipeline.py`)
Orchestrates: collectors → downloader → extractor → classifier → sync. Each stage is independently runnable via REST (`POST /collect`, `POST /extract/pending`, `POST /elastic/sync`) or CLI.

### Key Modules
- **`app/collectors/`** — Three scrapers: `primegov.py` (meeting agendas), `city_website.py`, `municipal_code.py`
- **`app/extractor.py`** — Gemini PDF→structured policy extraction
- **`app/classifier.py`** — GPT-4o-mini actionability/impact scoring
- **`app/elastic.py`** + **`app/sync.py`** — Elasticsearch hybrid search (BM25 + JINA semantic)
- **`app/agent.py`** — Claude research agent with tool use, streams via SSE
- **`app/agent_builder.py`** — Elastic Agent Builder A2A protocol integration
- **`app/diff_generator.py`** — AI policy revision generation
- **`app/database.py`** — SQLite schema and helpers (WAL mode); DB at `data/tribune.db`
- **`app/cities/`** — YAML city configs (e.g., `palo_alto.yaml`) for adding new municipalities

### Database Tables
Core: `policies`, `residents`, `interviews`, `quotes`, `diffs`, `analysis`, `raw_documents`. Calling: `campaigns`, `constituents`, `campaign_constituents`, `call_results`. System: `activity_log`.

## Frontend Architecture

Next.js App Router with `"use client"` components. Path alias: `@/*` → `./src/*`.

### Pages
- `/` — Landing page
- `/dashboard` — Stats, recent policies, next meeting
- `/policies` — List with category filters and pagination
- `/policies/[id]` — D
[truncated — 1224 more characters]
```

### backend/CLAUDE.md

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Important Coding Rules

Always be as concise as possible. Do not change anything else apart from what is asked. Do not write unnecessary code. Do not add comments.

## Commands

```bash
source .venv_voca/bin/activate
uvicorn app.main:app --reload --port 8000      # Core API
uvicorn server:app --reload --port 8001         # Voice calling server
python -m app                                    # Run full pipeline
```

## Architecture

Two FastAPI servers: `app/main.py` (REST API) and `server.py` (Twilio voice + OpenAI Realtime WebSocket).

Data pipeline (`app/pipeline.py`): collect → download → extract → classify → sync. Each stage runnable independently via API or CLI.

SQLite database at `data/tribune.db` (WAL mode). Schema defined in `app/database.py`. Elasticsearch for hybrid search via `app/elastic.py` + `app/sync.py`.

AI integrations: Gemini (extraction), GPT-4o-mini (classification), Claude (agent + diffs), OpenAI Realtime (voice), JINA (embeddings).

City configs in `app/cities/` as YAML files.

```

### backend/package.json

```
{
  "dependencies": {
    "react-markdown": "^10.1.0"
  }
}

```

### backend/requirements.txt

```
fastapi==0.115.0
uvicorn[standard]==0.30.0
python-dotenv==1.0.1
google-generativeai==0.8.0
openai==1.50.0
anthropic==0.34.0
httpx==0.27.0
httptools
elasticsearch==8.15.0
pydantic==2.9.0
python-multipart==0.0.9
websockets>=14.0
requests
pydub
pandas
twilio
pyyaml>=6.0
sse-starlette==2.0.0
```

### backend/Dockerfile

```
FROM python:3.11-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends gcc python3-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN mkdir -p /app/data_seed && cp data/tribune.db /app/data_seed/

EXPOSE 8000

CMD sh -c 'rm -rf /app/data/* && cp /app/data_seed/tribune.db /app/data/ && uvicorn app.main:app --host 0.0.0.0 --port 8000 --ws websockets --ws-per-message-deflate false'

```

### frontend/package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "framer-motion": "^12.34.0",
    "lucide-react": "^0.564.0",
    "mapbox-gl": "^3.18.1",
    "next": "16.1.6",
    "radix-ui": "^1.4.3",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-map-gl": "^8.1.0",
    "react-markdown": "^10.1.0",
    "react-pdf": "^10.3.0",
    "recharts": "^3.7.0",
    "tailwind-merge": "^3.4.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "shadcn": "^3.8.4",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.4.0",
    "typescript": "5.9.3"
  }
}

```

### backend/server.py

```python
import os
import json
import base64
import asyncio
import re
import uuid
import time
from fastapi import FastAPI, HTTPException, WebSocket
from fastapi.websockets import WebSocketDisconnect
from pydantic import BaseModel
from typing import Optional
from twilio.rest import Client
import websockets
from dotenv import load_dotenv
from openai import OpenAI

from app.database import get_db, init_db

load_dotenv()

TWILIO_ACCOUNT_SID = os.getenv('TWILIO_ACCOUNT_SID')
TWILIO_AUTH_TOKEN = os.getenv('TWILIO_AUTH_TOKEN')
PHONE_NUMBER_FROM = os.getenv('PHONE_NUMBER_FROM')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
raw_domain = os.getenv('DOMAIN', '')
DOMAIN = re.sub(r'(^\w+:|^)\/\/|\/+$', '', raw_domain)

PORT = int(os.getenv('PORT', 8000))
SYSTEM_MESSAGE = (
    "Make sure whenever you are speaking to the user, it flows well with the last thing just said (by either you or the user).Follow these instructions strictly: "
    "First, ask the user the initial question that will be provided."
    "Wait for the user to respond"
    "If the user responds yes, say 'Hi [FIRST NAME OF USER], I'm calling from the City Council office regarding the new zoning proposal for Ward 4. We're gathering feedback from residents to ensure your voice is heard before the upcoming vote. Do you have a moment to answer two quick questions about neighborhood development?'"
    "Then, if the user responds yes, say 'Great! First, are you aware of the proposed changes to the park district?'"
    "If the user responds no to the do you have a moment question, say 'I understand. Is there a better time we could call back later this week?'"
    "If the user responds no to the proposed changes to the park district question, say 'No problem. [DESCRIBE THE PROPOSED CHANGES TO THE PARK DISTRICT]'"
    "If the user interrupts, pause what you're saying and follow the yes/no instructions."
)
VOICE = 'alloy'
TEMPERATURE = float(os.getenv('TEMPERATURE', 0.8))
LOG_EVENT_TYPES = [
    'error', 'response.content.done', 'rate_limits.updated', 'response.done',
    'input_audio_buffer.committed', 'input_audio_buffer.speech_stopped',
    'input_audio_buffer.speech_started', 'session.created'
]

app = FastAPI()
init_db()

if not (TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and PHONE_NUMBER_FROM and OPENAI_API_KEY):
    raise ValueError('Missing Twilio and/or OpenAI environment variables. Please set them in the .env file.')

twilio_client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
openai_client = OpenAI(api_key=OPENAI_API_KEY)


@app.get("/")
async def index():
    return {"status": "running"}


@app.websocket('/media-stream')
async def handle_media_stream(websocket: WebSocket):
    print("Client connected")
    await websocket.accept()

    openai_ws = await websockets.connect(
        f"wss://api.openai.com/v1/realtime?model=gpt-realtime&temperature={TEMPERATURE}",
        additional_headers={
            "Authorization": f"Bearer {OPENAI_API_KEY}"
        }
    )

    stream_sid = None
    caller_name = "there"
    campaign_id = None
    constituent_id = None
    phone = None
    call_sid = None
    transcript = []
    call_start_time = time.time()

    async def receive_from_twilio():
        nonlocal stream_sid, caller_name, campaign_id, constituent_id, phone, call_sid
        try:
            async for message in websocket.iter_text():
                data = json.loads(message)
                if data['event'] == 'media' and openai_ws.state.name == 'OPEN':
                    audio_append = {
                        "type": "input_audio_buffer.append",
                        "audio": data['media']['payload']
                    }
                    await openai_ws.send(json.dumps(audio_append))
                elif data['event'] == 'start':
                    stream_sid = data['start']['streamSid']
                    params = data['start'].get('customParameters', {})
                    caller_name = params.get('name', 'there')
                    campaign_id = params.get('campaign_id')
                    constituent_id = params.get('constituent_id')
                    phone = params.get('phone')
                    call_sid = data['start'].get('callSid')
                    print(f"Incoming stream has started {stream_sid}")
                    await initialize_session(openai_ws, caller_name)
                elif data['event'] == 'stop':
                    print("Twilio stream stopped (call ended).")
                    if openai_ws.state.name == 'OPEN':
                        await openai_ws.close()
                    return
        except WebSocketDisconnect:
            print("Client disconnected.")
            if openai_ws.state.name == 'OPEN':
                await openai_ws.close()
        except Exception as e:
            print(f"Error in receive_from_twilio: {e}")
            if openai_ws.state.name == 'OPEN':
                await openai_ws.close()

    async def send_to_twilio():
        nonlocal stream_sid
        try:
            async for openai_message in openai_ws:
                response = json.loads(openai_message)
                if response['type'] in LOG_EVENT_TYPES:
                    print(f"Received event: {response['type']}", response)
                if response['type'] == 'session.updated':
                    print("Session updated successfully:", response)
                if response['type'] == 'response.output_audio.delta' and response.get('delta'):
                    try:
                        audio_payload = base64.b64encode(base64.b64decode(response['delta'])).decode('utf-8')
                        audio_delta = {
                            "event": "media",
                            "streamSid": stream_sid,
                            "media": {
                                "payload": audio_payload
                            }
                        }
                        await websocket.send_json(audio_delta)
                    except Exception as e:
                        print(
[truncated — 9667 more characters]
```

### frontend/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { DM_Sans, Newsreader } from "next/font/google";
import { CityProvider } from "@/lib/city-context";
import "./globals.css";

const dmSans = DM_Sans({
  variable: "--font-dm-sans",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
});

const newsreader = Newsreader({
  variable: "--font-newsreader",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
  style: ["normal", "italic"],
});

export const metadata: Metadata = {
  title: "Tribune",
  description: "GitHub for Democracy. Turn town halls into pull requests.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${dmSans.variable} ${newsreader.variable} font-sans antialiased`}
      >
        <CityProvider>{children}</CityProvider>
      </body>
    </html>
  );
}

```

### frontend/src/app/login/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Landmark, MapPin, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { useCity } from "@/lib/city-context";

interface CityOption {
  slug: string;
  name: string;
  state: string;
}

const CITY_META: Record<string, { icon: string; description: string }> = {
  palo_alto: {
    icon: "🌳",
    description: "Silicon Valley municipality with active zoning and housing policy debates",
  },
  nyc: {
    icon: "🏙️",
    description: "America's largest city — housing, transit, and land use at massive scale",
  },
};

export default function LoginPage() {
  const [cities, setCities] = useState<CityOption[]>([]);
  const { setCity } = useCity();
  const router = useRouter();

  useEffect(() => {
    api.listCities().then(setCities).catch(() => {
      setCities([
        { slug: "palo_alto", name: "Palo Alto", state: "CA" },
        { slug: "nyc", name: "New York City", state: "NY" },
      ]);
    });
  }, []);

  const handleSelect = (c: CityOption) => {
    setCity(c);
    router.push("/dashboard");
  };

  return (
    <div className="min-h-screen bg-[var(--background)] flex flex-col items-center justify-center px-6">
      {/* Brand */}
      <div className="flex items-center gap-3 mb-1.5">
        <div className="flex h-8 w-8 items-center justify-center bg-[var(--primary)]/[0.06] ring-1 ring-inset ring-[var(--primary)]/[0.08]">
          <Landmark className="h-4 w-4 text-[var(--primary)]/60" />
        </div>
        <h1 className="text-xl font-serif font-semibold tracking-tight">Tribune</h1>
      </div>
      <p className="text-[13px] text-[var(--muted-foreground)] mb-10">
        Select your city to continue
      </p>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-5 max-w-[520px] w-full">
        {cities.map((c) => {
          const meta = CITY_META[c.slug] || { icon: "🏛️", description: "Municipal policy platform" };
          return (
            <button
              key={c.slug}
              onClick={() => handleSelect(c)}
              className="card-elevated group text-left p-6 rounded-xl border border-[var(--border)]/60 bg-[var(--card)]"
            >
              <div className="text-2xl mb-3">{meta.icon}</div>
              <div className="flex items-center gap-2 mb-1.5">
                <h2 className="font-serif font-semibold text-[1.0625rem] tracking-tight">{c.name}</h2>
                <span className="text-[11px] text-[var(--muted-foreground)] flex items-center gap-0.5">
                  <MapPin className="h-2.5 w-2.5" />{c.state}
                </span>
              </div>
              <p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed mb-5">
                {meta.description}
              </p>
              <span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-[var(--primary)]/70 group-hover:text-[var(--primary)] group-hover:gap-2.5 transition-all">
                Enter
                <ArrowRight className="h-3 w-3" />
              </span>
            </button>
          );
        })}
      </div>

      <p className="mt-12 text-[11px] tracking-[0.06em] text-[var(--muted-foreground)]/40">
        Built at Stanford TreeHacks 2026
      </p>
    </div>
  );
}

```

### frontend/src/app/policies/page.tsx

```typescript
"use client";

import { Suspense, useEffect, useState, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Clock, MapPin } from "lucide-react";
import { api } from "@/lib/api";
import { useCity } from "@/lib/city-context";
import type { Policy } from "@/lib/types";

const STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
  new: { label: "New", variant: "outline" },
  interviewing: { label: "Interviewing", variant: "secondary" },
  analyzed: { label: "Analyzed", variant: "default" },
  revised: { label: "Revised", variant: "default" },
};

const PAGE_SIZE = 20;

function formatCategory(cat: string) {
  return cat.replace(/_/g, " ");
}

export default function PoliciesPage() {
  return (
    <Suspense>
      <PoliciesContent />
    </Suspense>
  );
}

function PoliciesContent() {
  const { city } = useCity();
  const searchParams = useSearchParams();
  const [policies, setPolicies] = useState<Policy[]>([]);
  const [categories, setCategories] = useState<string[]>([]);
  const [activeCategory, setActiveCategory] = useState(searchParams.get("category") || "");
  const [loading, setLoading] = useState(true);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(0);
  const [totalCount, setTotalCount] = useState(0);

  useEffect(() => {
    api.getCategories().then(setCategories).catch(console.error);
  }, []);

  useEffect(() => {
    setLoading(true);
    setPolicies([]);
    setPage(0);
    setHasMore(true);
    Promise.all([
      api.listPolicies({ limit: PAGE_SIZE, offset: 0, category: activeCategory || undefined }),
      api.getPolicyCount(activeCategory || undefined),
    ])
      .then(([p, c]) => {
        setPolicies(p);
        setTotalCount(c.count);
        setHasMore(p.length < c.count);
      })
      .catch(console.error)
      .finally(() => setLoading(false));
  }, [activeCategory]);

  function loadMore() {
    const nextOffset = (page + 1) * PAGE_SIZE;
    api
      .listPolicies({ limit: PAGE_SIZE, offset: nextOffset, category: activeCategory || undefined })
      .then((p) => {
        const newPolicies = [...policies, ...p];
        setPolicies(newPolicies);
        setHasMore(newPolicies.length < totalCount);
        setPage((prev) => prev + 1);
      })
      .catch(console.error);
  }

  if (loading) {
    return (
      <AppShell>
        <div className="mb-8 animate-fade-in">
          <div className="skeleton-block h-3 w-24 mb-3" />
          <div className="skeleton-block h-8 w-48 mb-2" />
          <div className="skeleton-block h-4 w-64" />
        </div>
        <div className="space-y-4">
          {Array.from({ length: 4 }).map((_, i) => (
            <Card key={i} className="card-elevated">
              <CardContent className="p-5">
                <div className="skeleton-block h-3 w-20 mb-3" />
                <div className="skeleton-block h-5 w-3/4 mb-2" />
                <div className="skeleton-block h-4 w-full mb-1" />
                <div className="skeleton-block h-4 w-2/3" />
              </CardContent>
            </Card>
          ))}
        </div>
      </AppShell>
    );
  }

  return (
    <AppShell>
      <div className="mb-8 animate-fade-in-up">
        <p className="text-label mb-2">Policy Tracker</p>
        <h1 className="text-headline-xl">All Policies</h1>
        <p className="mt-2 text-muted-foreground">
          {totalCount} policies tracked for the {city?.name || "City"} Council.
        </p>
        <div className="mt-5 h-px bg-gradient-to-r from-border via-border/60 to-transparent" />
      </div>

      {categories.length > 0 && (
        <div className="flex flex-wrap gap-2 mb-6 animate-fade-in-up stagger-1">
          <button
            onClick={() => setActiveCategory("")}
            className={`rounded-full px-3.5 py-1.5 text-sm transition-all ${
              !activeCategory
                ? "bg-primary text-primary-foreground shadow-sm"
                : "card-surface text-muted-foreground hover:bg-secondary hover:text-foreground"
            }`}
          >
            All
          </button>
          {categories.map((cat) => (
            <button
              key={cat}
              onClick={() => setActiveCategory(cat)}
              className={`rounded-full px-3.5 py-1.5 text-sm capitalize transition-all ${
                activeCategory === cat
                  ? "bg-primary text-primary-foreground shadow-sm"
                  : "card-surface text-muted-foreground hover:bg-secondary hover:text-foreground"
              }`}
            >
              {formatCategory(cat)}
            </button>
          ))}
        </div>
      )}

      <div className="space-y-4">
        {policies.map((policy, i) => (
          <Link key={policy.id} href={`/policies/${policy.id}`} className="block">
            <Card className={`group card-elevated cursor-pointer animate-fade-in-up stagger-${Math.min(i + 1, 8)}`}>
              <CardContent className="p-5">
                <div className="flex items-start justify-between">
                  <div className="flex-1">
                    <div className="flex items-center gap-2 mb-2">
                      {policy.status !== "new" && (
                        <Badge variant={STATUS_CONFIG[policy.status]?.variant ?? "outline"}>
                          {STATUS_CONFIG[policy.status]?.label ?? policy.status}
                        </Badge>
                      )}
                      {policy.category && (
                        <span className="text-xs text-muted-foreground capitalize">{formatCategory(policy.category)}</span>
                      )}
                    </div>
                    <h3 className="text-base font-semibold font-serif group-hover:text-prima
[truncated — 1808 more characters]
```

[82 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]