voting app tracker

This commit is contained in:
jarianc 2026-05-15 15:16:18 -05:00
parent 004aef2020
commit a9448e2969
55 changed files with 5498 additions and 0 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
.env
.git
node_modules
__pycache__
*.pyc
dist
build
.venv
data
logs
*.log
tests
tools
additionaldocs

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# Congress API Configuration
CONGRESS_API_KEY=your_legiscan_api_key_here
CONGRESS_API_BASE_URL=https://api.legiscan.com
# GPT-OSS Configuration
GPT_OSS_BASE_URL=http://home.ms:4000/v1
GPT_OSS_API_KEY=your_gpt_oss_api_key_here
# Backend Application Settings
BACKEND_PORT=8001
APP_ENV=development
LOG_LEVEL=info
# Frontend Configuration
VITE_FRONTEND_PORT=5174
VITE_API_URL=http://localhost:8001
VITE_APP_TITLE=Voting App
# Docker Configuration
COMPOSE_PROJECT_NAME=voting-app

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*.so
*.egg-info/
.eggs/
dist/
build/
.venv/
uv.lock
*.pyc
# Frontend
frontend/dist/
frontend/node_modules/
frontend/pnpm-lock.yaml
# Data
data/*
!data/.gitkeep
# Environment
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
docker/init-scripts/*.sql
# Logs
logs/
*.logs
.playwright-cli/

6
.hadolint.yaml Normal file
View File

@ -0,0 +1,6 @@
failure-threshold: warning
ignored:
# DL3008: Add --no-install-recommends to apt-get install
# We accept this trade-off for simpler Dockerfiles and smaller images
- DL3008

97
AGENTS.md Normal file
View File

@ -0,0 +1,97 @@
# Voting App - Agent Instructions
See `~/.config/opencode/AGENTS.md` for global development principles (container-first, package managers, Dockerfile standards).
## Repo Overview
Flask backend + React TypeScript frontend. Data flow: `User → React Frontend → Flask Backend → Congress.gov v3 API`, with SQLite caching and local GPT-OSS for bill summaries. Backend source is flat under `src/` (no submodules). Vote data: House votes from Clerk XML (fast), Senate votes from senate.gov XML (slow, sequential scan).
## Lockfiles Are Gitignored — Regenerate Before Building
Both `uv.lock` and `frontend/pnpm-lock.yaml` are in `.gitignore`. Dockerfiles copy them during build, so they must exist locally first:
```bash
uv sync # generates uv.lock
cd frontend && pnpm install # generates pnpm-lock.yaml
```
Skip regeneration only if you haven't changed dependencies.
## Port Mapping
Host ports differ from container ports:
| Service | Host Port | Container Port |
|----------|-----------|----------------|
| Backend | 8001 | 8000 |
| Frontend | 5174 | 5173 |
## VITE_API_URL Is Build-Time Only
`VITE_API_URL` is passed as a Docker build arg (`http://backend:8000`) and baked into the frontend bundle at build time. It is NOT a runtime environment variable — changing `.env` after building has no effect. Rebuild the frontend container to pick up URL changes.
## Frontend: Preview Mode, No HMR
`docker-compose.yml` runs `npx vite preview`, serving the production build from `dist/`. There is no hot module reload. For active frontend development, rebuild after every change:
```bash
docker compose down frontend && docker compose up -d frontend --build
```
The `../frontend:/app/frontend` volume mount in docker-compose has no effect in preview mode (serves from baked-in `dist/`).
## Backend Entry Point
Runs as `python -m src.main --port 8000` inside the container. The `--port 8000` flag is hardcoded in docker-compose (mapped to 8001 on host).
## SQLite Cache
Backend caches Congress.gov API responses and GPT-OSS summaries in SQLite at `data/voting_app.db`. The `data/` directory is mounted as a Docker volume to `/app/data`. Not committed to git. Delete stale DB after schema changes (e.g. adding `start_date` column).
## Required External Services
- **Congress.gov API key**: Set `CONGRESS_API_KEY` in `.env`
- **Local GPT-OSS server**: Set `GPT_OSS_BASE_URL` in `.env` (e.g., LM Studio, Ollama, vLLM)
## Quality Gate Commands
Run before building any container:
```bash
# Backend
ruff format .
ruff check --fix .
# Frontend
cd frontend && pnpm lint --fix
cd frontend && tsc
```
Ruff: line-length 120, rules `E W F I N UP B SIM`.
Frontend ESLint: `@typescript-eslint/no-unused-vars` and `no-explicit-any` are `warn`.
Frontend `pnpm build` runs `tsc && vite build` — TypeScript check gates the build.
## Testing
106 tests total: 103 unit/E2E tests (mocked services) + 3 integration tests (real API). All pass.
```bash
# Unit + E2E tests (fast, mocked)
uv run pytest tests/unit/ tests/e2e/
# Integration tests (slow, real API — requires CONGRESS_API_KEY)
uv run pytest tests/integration/
# In container
docker compose exec backend python -m pytest tests/unit/ tests/e2e/
```
The `integration` pytest mark is registered in `pyproject.toml`. Integration tests require a valid `CONGRESS_API_KEY` in `.env` and skip if unset. Senate vote fetching uses XML scanning from senate.gov and can take 2-4 minutes per legislator.
## Backend Dockerfile Note
The backend Dockerfile installs `uv` via `pip` (the `python:3.12-slim` image doesn't include it). Build arg `VITE_API_URL` is set via docker-compose, not `.env`.
## Build Context
`.dockerignore` excludes `tests`, `tools`, `additionaldocs`, `data`, and `.env` from Docker build context. A `.dockerignore.backend` file exists in `docker/` but is not used by Docker (context is project root).

View File

@ -0,0 +1,20 @@
node_modules
__pycache__
*.pyc
dist
build
.venv
data
logs
*.log
tests
tools
additionaldocs
frontend
.git
.gitignore
.vscode
.idea
*.swp
.DS_Store
prompt-v0.1.md

24
docker/Dockerfile.backend Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv==0.5.25 && uv sync --frozen
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r appuser && useradd -r -g appuser -d /app -m appuser
WORKDIR /app
COPY --from=builder /build/.venv /app/.venv
COPY src/ ./src/
ENV PATH="/app/.venv/bin:$PATH"
RUN mkdir -p /app/data && chown -R appuser:appuser /app
USER appuser

View File

@ -0,0 +1,33 @@
FROM node:22-alpine AS builder
WORKDIR /build
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
RUN corepack enable \
&& pnpm install --frozen-lockfile --ignore-scripts \
&& pnpm rebuild esbuild
COPY frontend/ ./
RUN CI=true pnpm build
FROM node:22-alpine
RUN addgroup -S appuser && adduser -S appuser -G appuser
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/package.json ./package.json
ENV NODE_ENV=production
USER appuser

39
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
services:
backend:
build:
context: ..
dockerfile: docker/Dockerfile.backend
container_name: voting-app-backend
ports:
- "8001:8000"
env_file:
- ../.env
volumes:
- ../data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
command: ["python", "-m", "src.main", "--port", "8000"]
frontend:
build:
context: ..
dockerfile: docker/Dockerfile.frontend
args:
VITE_API_URL: http://localhost:8001
VITE_APP_TITLE: ${VITE_APP_TITLE:-Voting App}
container_name: voting-app-frontend
ports:
- "5174:5173"
env_file:
- ../.env
volumes:
- ../frontend:/app/frontend
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
command: ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "5173"]

10
frontend/.dockerignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
.vscode
.idea
*.swp
.DS_Store
src
..

23
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['react-refresh', '@typescript-eslint', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
},
settings: {
react: {
version: 'detect',
},
},
};

8
frontend/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"useTabs": false
}

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voting App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "voting-app-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext .ts,.tsx",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.50.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}

View File

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

17
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,17 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { Home } from './pages/Home';
import { LegislatorDetail } from './pages/LegislatorDetail';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/legislator/:id" element={<LegislatorDetail />} />
</Route>
</Routes>
</BrowserRouter>
);
}

View File

@ -0,0 +1,86 @@
import { useMemo } from 'react';
import type { VoteRecord } from '../../types/api';
import { useAppStore } from '../../store/appStore';
interface VoteCardProps {
vote: VoteRecord;
}
export function VoteCard({ vote }: VoteCardProps) {
const { expandedBillId, setExpandedBillId } = useAppStore();
const expandKey = vote.bill_id || vote.roll_call_id;
const isExpanded = expandedBillId === expandKey;
const voteClass = useMemo(() => {
const v = vote.vote_type.toLowerCase();
if (v === 'yea' || v === 'y') return 'vote-yay';
if (v === 'nay' || v === 'n') return 'vote-nay';
if (v === 'present') return 'vote-abstain';
return 'vote-notvoting';
}, [vote.vote_type]);
const toggleExpand = () => {
setExpandedBillId(isExpanded ? null : expandKey);
};
return (
<div className={`bill-card ${isExpanded ? 'bill-card-expanded' : ''}`}>
<div className="flex items-center justify-between" onClick={toggleExpand}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`px-2.5 py-0.5 text-xs font-bold uppercase rounded-full border ${voteClass}`}>
{vote.vote_type}
</span>
<span className="font-medium text-gray-900">{vote.bill_title}</span>
</div>
{vote.vote_date && (
<span className="text-xs text-gray-500">{vote.vote_date}</span>
)}
{vote.subject && (
<span className="block text-xs text-gray-400 mt-0.5">{vote.subject}</span>
)}
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-100">
{vote.summary ? (
<>
<p className="text-sm text-gray-700 leading-relaxed">{vote.summary}</p>
{vote.key_measures.length > 0 && (
<div className="mt-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Key Measures
</h4>
<ul className="mt-1 space-y-1">
{vote.key_measures.map((measure, idx) => (
<li key={idx} className="text-sm text-gray-600 flex gap-2">
<span className="text-primary-500"></span>
<span>{measure}</span>
</li>
))}
</ul>
</div>
)}
</>
) : vote.bill_id ? (
<div className="flex items-center gap-2 text-sm text-amber-600">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
<span>Summary pending will populate shortly</span>
</div>
) : (
<p className="text-sm text-gray-400 italic">Procedural vote no summary available</p>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store/appStore';
import { Input } from '../ui/Input';
const DEBOUNCE_MS = 300;
export function SearchForm() {
const navigate = useNavigate();
const { searchQuery, searchResults, isSearching, setSearchQuery, searchLegislators, selectLegislator } = useAppStore();
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (searchQuery.length >= 2) {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
searchLegislators(searchQuery);
setShowSuggestions(true);
setActiveIndex(0);
}, DEBOUNCE_MS);
} else {
setShowSuggestions(false);
}
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery, searchLegislators]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!showSuggestions || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, searchResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && showSuggestions) {
e.preventDefault();
const selected = searchResults[activeIndex];
if (selected) {
selectLegislator(selected.id);
setShowSuggestions(false);
navigate(`/legislator/${selected.id}`);
}
} else if (e.key === 'Escape') {
setShowSuggestions(false);
}
};
const handleSelect = (legislator: typeof searchResults[0]) => {
selectLegislator(legislator!.id);
setShowSuggestions(false);
setSearchQuery('');
navigate(`/legislator/${legislator!.id}`);
};
const partyColor = (party: string) => {
if (party.toLowerCase().includes('dem')) return 'text-blue-600 bg-blue-100';
if (party.toLowerCase().includes('rep')) return 'text-red-600 bg-red-100';
return 'text-gray-600 bg-gray-100';
};
return (
<div ref={containerRef} className="relative">
<Input
value={searchQuery}
onChange={(q) => {
setSearchQuery(q);
setActiveIndex(0);
}}
placeholder="Search for a legislator..."
onKeyDown={handleKeyDown}
/>
{showSuggestions && searchResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-auto">
{searchResults.map((leg, idx) => (
<div
key={leg.id}
className={`suggestion-item ${idx === activeIndex ? 'suggestion-item-active' : ''}`}
onClick={() => handleSelect(leg)}
>
<div>
<span className="font-medium">{leg.full_name}</span>
{leg.photo_url && (
<img src={leg.photo_url} alt="" className="w-6 h-6 rounded-full inline-block ml-2" />
)}
<div className="text-xs text-gray-500">{leg.state} {leg.chamber}</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${partyColor(leg.party)}`}>
{leg.party}
</span>
</div>
))}
</div>
)}
{isSearching && (
<div className="absolute right-3 top-3">
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
export function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-primary-900 text-white shadow-lg">
<div className="max-w-4xl mx-auto px-4 py-4">
<h1 className="text-xl font-bold">Voting App</h1>
<p className="text-sm text-primary-300">U.S. Congress Voting History</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-6">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { forwardRef } from 'react';
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
className?: string;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, variant = 'primary', size = 'md', disabled, loading, onClick, className = '' }, ref) => {
const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors';
const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 disabled:bg-primary-300',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 disabled:bg-gray-100',
ghost: 'text-primary-600 hover:bg-primary-50 disabled:text-primary-300',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
ref={ref}
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
onClick={onClick}
>
{loading && (
<svg className="w-4 h-4 mr-2 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0116 0 8 8 0 01-16 0z" />
</svg>
)}
{children}
</button>
);
},
);
Button.displayName = 'Button';

View File

@ -0,0 +1,32 @@
import { forwardRef, useRef } from 'react';
interface InputProps {
value: string;
onChange: (value: string) => void;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ value, onChange, onKeyDown, placeholder, disabled, className = '' }, ref) => {
const innerRef = useRef<HTMLInputElement>(null);
const combinedRef = ref || innerRef;
return (
<input
ref={combinedRef}
type="text"
className={`search-input ${className}`}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
/>
);
},
);
Input.displayName = 'Input';

View File

@ -0,0 +1,14 @@
export function Spinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<svg className={`${sizes[size]} animate-spin`} viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0116 0 8 8 0 01-16 0z" />
</svg>
);
}

View File

@ -0,0 +1,3 @@
export { Button } from './Button';
export { Input } from './Input';
export { Spinner } from './Spinner';

View File

@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { request } from '../services/api';
export function useApi<T>(endpoint: string, deps: any[] = []) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const fetcher = async () => {
try {
const result = await request<T>(endpoint);
if (!cancelled) {
setData(result);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Request failed');
setLoading(false);
}
}
};
fetcher();
return () => {
cancelled = true;
};
}, deps);
return { data, loading, error };
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,57 @@
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/appStore';
import { SearchForm } from '../components/forms/SearchForm';
export function Home() {
const navigate = useNavigate();
const { searchQuery, selectLegislator } = useAppStore();
const handleSearch = async () => {
if (!searchQuery.trim()) return;
await useAppStore.getState().searchLegislators(searchQuery);
const results = useAppStore.getState().searchResults;
if (results.length > 0) {
await selectLegislator(results[0].id);
navigate(`/legislator/${results[0].id}`);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Track Legislative Voting Records</h2>
<p className="text-gray-600">
Search for any U.S. Congress legislator to view their complete voting history
with AI-generated bill summaries.
</p>
</div>
<div className="w-full max-w-xl">
<div className="flex gap-2">
<SearchForm />
<button
onClick={handleSearch}
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium whitespace-nowrap"
>
Search
</button>
</div>
</div>
<div className="mt-12 grid grid-cols-3 gap-8 text-center w-full max-w-lg">
<div>
<div className="text-2xl font-bold text-primary-600">Search</div>
<p className="text-sm text-gray-500 mt-1">Find any legislator</p>
</div>
<div>
<div className="text-2xl font-bold text-primary-600">Explore</div>
<p className="text-sm text-gray-500 mt-1">View voting history</p>
</div>
<div>
<div className="text-2xl font-bold text-primary-600">Understand</div>
<p className="text-sm text-gray-500 mt-1">AI bill summaries</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/appStore';
import { VoteCard } from '../components/common/VoteCard';
import { SearchForm } from '../components/forms/SearchForm';
import { Spinner } from '../components/ui/Spinner';
const partyColor = (party: string) => {
if (party.toLowerCase().includes('dem')) return 'text-blue-700 bg-blue-100';
if (party.toLowerCase().includes('rep')) return 'text-red-700 bg-red-100';
return 'text-gray-700 bg-gray-100';
};
export function LegislatorDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const {
selectedLegislator,
isLoadingLegislator,
votes,
isLoadingVotes,
voteHasMore,
voteTotal,
selectLegislator,
loadVotes,
refreshVotes,
} = useAppStore();
const observerRef = useRef<HTMLDivElement>(null);
const [hasPending, setHasPending] = useState(false);
useEffect(() => {
if (id) {
selectLegislator(id);
}
}, [id, selectLegislator]);
useEffect(() => {
if (voteHasMore && !isLoadingVotes) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadVotes();
}
},
{ rootMargin: '200px' },
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}
}, [voteHasMore, isLoadingVotes, loadVotes]);
useEffect(() => {
const pending = votes.some((v) => !v.summary);
setHasPending(pending);
}, [votes]);
useEffect(() => {
if (!hasPending || !selectedLegislator || isLoadingVotes) return;
const interval = setInterval(() => {
refreshVotes();
}, 5000);
return () => clearInterval(interval);
}, [hasPending, selectedLegislator, isLoadingVotes, refreshVotes]);
const handleBack = () => {
navigate('/');
useAppStore.getState().resetVotes();
useAppStore.getState().setSelectedLegislator(null);
};
if (isLoadingLegislator && !selectedLegislator) {
return (
<div className="flex flex-col items-center justify-center py-12">
<Spinner size="lg" />
<p className="mt-4 text-gray-600">Loading legislator information...</p>
</div>
);
}
if (!selectedLegislator) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-gray-600">Legislator not found.</p>
<button
onClick={handleBack}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Go back
</button>
</div>
);
}
return (
<div>
<div className="mb-6">
<SearchForm />
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{selectedLegislator.full_name}</h2>
<div className="flex flex-wrap gap-2 mt-2">
<span className={`px-2.5 py-0.5 text-sm font-medium rounded-full ${partyColor(selectedLegislator.party)}`}>
{selectedLegislator.party}
</span>
<span className="px-2.5 py-0.5 text-sm font-medium rounded-full bg-gray-100 text-gray-700">
{selectedLegislator.state}
</span>
<span className="px-2.5 py-0.5 text-sm font-medium rounded-full bg-gray-100 text-gray-700">
{selectedLegislator.chamber}
</span>
<span className="px-2.5 py-0.5 text-sm font-medium rounded-full bg-gray-100 text-gray-700">
{selectedLegislator.in_office ? 'In Office' : 'Former'}
</span>
</div>
</div>
<button
onClick={handleBack}
className="text-primary-600 hover:text-primary-800 text-sm font-medium"
>
Back to search
</button>
</div>
<div className="mt-3 text-sm text-gray-500">
{voteTotal} total votes recorded
</div>
</div>
<div className="space-y-3">
{votes.map((vote) => (
<VoteCard key={`${vote.roll_call_id}-${vote.bill_id}`} vote={vote} />
))}
</div>
{isLoadingVotes && (
<div className="flex items-center justify-center py-6">
<Spinner />
<span className="ml-2 text-gray-600">Loading more votes...</span>
</div>
)}
{!voteHasMore && votes.length > 0 && (
<div className="text-center py-6 text-gray-500">
End of voting history
</div>
)}
{votes.length === 0 && !isLoadingVotes && !isLoadingLegislator && (
<div className="text-center py-12 text-gray-500">
No voting records found
</div>
)}
{voteHasMore && (
<div ref={observerRef} className="h-1" />
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
import type { Legislator } from '../types/api';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
async function request<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
}
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return response.json();
}
export { request };
export const apiService = {
searchLegislators: (query: string): Promise<Legislator[]> =>
request<Legislator[]>('/api/search', { q: query }),
getLegislator: (id: string): Promise<Legislator> =>
request<Legislator>(`/api/legislators/${id}`),
getLegislatorVotes: (
legislatorId: string,
limit: number,
offset: number,
): Promise<any> =>
request<any>(`/api/legislators/${legislatorId}/votes`, {
limit: String(limit),
offset: String(offset),
}),
getBillSummary: (billId: string): Promise<any> =>
request<any>(`/api/bills/${billId}/summary`),
getBillText: (billId: string): Promise<any> =>
request<any>(`/api/bills/${billId}/text`),
healthCheck: (): Promise<{ status: string }> =>
request<{ status: string }>('/health'),
};

View File

@ -0,0 +1,129 @@
import { create } from 'zustand';
import type { Legislator, VotingResponse } from '../types/api';
import { apiService } from '../services/api';
interface AppState {
// Search
searchQuery: string;
searchResults: Legislator[];
isSearching: boolean;
setSearchQuery: (query: string) => void;
searchLegislators: (query: string) => Promise<void>;
// Selected legislator
selectedLegislator: Legislator | null;
isLoadingLegislator: boolean;
selectLegislator: (id: string) => Promise<void>;
setSelectedLegislator: (legislator: Legislator | null) => void;
// Voting record
votes: any[];
voteOffset: number;
voteLimit: number;
voteTotal: number;
voteHasMore: boolean;
isLoadingVotes: boolean;
loadVotes: (limit?: number) => Promise<void>;
resetVotes: () => void;
refreshVotes: () => Promise<void>;
// Bill detail
expandedBillId: string | null;
setExpandedBillId: (billId: string | null) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
searchQuery: '',
searchResults: [],
isSearching: false,
setSearchQuery: (query) => set({ searchQuery: query }),
searchLegislators: async (query) => {
if (!query.trim()) {
set({ searchResults: [], isSearching: false });
return;
}
set({ isSearching: true });
try {
const results = await apiService.searchLegislators(query);
set({ searchResults: results });
} catch {
set({ searchResults: [] });
} finally {
set({ isSearching: false });
}
},
selectedLegislator: null,
isLoadingLegislator: false,
selectLegislator: async (id) => {
set({ isLoadingLegislator: true, expandedBillId: null });
get().resetVotes();
try {
const legislator = await apiService.getLegislator(id);
set({ selectedLegislator: legislator });
await get().loadVotes();
} catch {
set({ selectedLegislator: null });
} finally {
set({ isLoadingLegislator: false });
}
},
setSelectedLegislator: (legislator) => set({ selectedLegislator: legislator }),
votes: [],
voteOffset: 0,
voteLimit: 20,
voteTotal: 0,
voteHasMore: false,
isLoadingVotes: false,
loadVotes: async (limit) => {
const { selectedLegislator, voteLimit, voteOffset } = get();
if (!selectedLegislator) return;
const l = limit ?? voteLimit;
set({ isLoadingVotes: true });
try {
const response: VotingResponse = await apiService.getLegislatorVotes(
selectedLegislator.id,
l,
voteOffset,
);
set({
votes: [...get().votes, ...response.votes],
voteOffset: voteOffset + l,
voteTotal: response.total,
voteHasMore: response.has_more,
});
} catch {
// Silent fail
} finally {
set({ isLoadingVotes: false });
}
},
resetVotes: () => set({ votes: [], voteOffset: 0, voteTotal: 0, voteHasMore: false }),
refreshVotes: async () => {
const { selectedLegislator, voteLimit } = get();
if (!selectedLegislator) return;
set({ votes: [], voteOffset: 0, voteTotal: 0, voteHasMore: false, isLoadingVotes: true });
try {
const response: VotingResponse = await apiService.getLegislatorVotes(
selectedLegislator.id,
voteLimit,
0,
);
set({
votes: response.votes,
voteOffset: voteLimit,
voteTotal: response.total,
voteHasMore: response.has_more,
});
} catch {
// Silent fail
} finally {
set({ isLoadingVotes: false });
}
},
expandedBillId: null,
setExpandedBillId: (billId) => set({ expandedBillId: billId }),
}));

View File

@ -0,0 +1,48 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: system-ui, -apple-system, sans-serif;
}
}
@layer components {
.vote-yay {
@apply bg-green-100 text-green-800 border-green-300;
}
.vote-nay {
@apply bg-red-100 text-red-800 border-red-300;
}
.vote-abstain {
@apply bg-yellow-100 text-yellow-800 border-yellow-300;
}
.vote-notvoting {
@apply bg-gray-100 text-gray-600 border-gray-300;
}
.bill-card {
@apply bg-white rounded-lg border border-gray-200 p-4 shadow-sm transition-all duration-200 cursor-pointer hover:shadow-md hover:border-primary-300;
}
.bill-card-expanded {
@apply border-primary-400 shadow-md;
}
.search-input {
@apply w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.suggestion-item {
@apply px-4 py-2 cursor-pointer hover:bg-primary-50 flex items-center justify-between;
}
.suggestion-item-active {
@apply bg-primary-100;
}
}

53
frontend/src/types/api.ts Normal file
View File

@ -0,0 +1,53 @@
export interface Legislator {
id: string;
full_name: string;
first_name: string;
last_name: string;
party: string;
state: string;
chamber: string;
in_office: boolean;
photo_url?: string;
url?: string;
}
export interface VoteRecord {
roll_call_id: string;
vote_type: string;
vote_date: string | null;
bill_id: string | null;
bill_title: string;
subject: string;
sponsor: string;
enacted: boolean;
summary: string;
key_measures: string[];
generated_at: string | null;
}
export interface VotingResponse {
legislator_id: string;
votes: VoteRecord[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface BillSummary {
bill_id: string;
summary_text: string;
key_measures: string[];
generated_at: string | null;
model_name: string;
}
export interface BillText {
bill_id: string;
title: string;
subject: string;
text: string;
sponsor: string;
committee: string;
enacted: boolean;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
},
},
plugins: [],
};

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"composite": true,
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
});

56
prompt-v0.1.md Normal file
View File

@ -0,0 +1,56 @@
> **Task**: Design a Python application that:
>
> 1. Authenticates with the U.S. Congress API (Library of Congress web service).
> 2. Retrieves the full voting history for any legislator (by name or ID).
> 3. For every bill the legislator voted on, fetches the bill text and a concise summary.
> 4. Feeds that bill text into a locallyhosted GPTOSS endpoint (`http://home.ms:4000/v1`) and stores the models output.
> 5. Caches all external data locally (SQLite or a simple keyvalue store) to avoid repeated API calls and to survive API outages.
> 6. Handles pagination, ratelimit backoff, and graceful error handling.
> 7. Provides a clean CLI or minimal web UI to query a legislator name and print:
>
> - Legislator details (party, state, etc.)
> - A table of bill IDs, vote type (Yay/Nay/Abstain), and a short GPTOSSderived explanation for each bill.
>
> 8. Uses typehints, dataclasses, and a modular architecture so each component can be unittested.
> 9. Includes a README explaining how to set up the API key, run the local GPTOSS server, and execute the program.
> **Desired Output**:
>
> - An overall architecture diagram (textbased or Markdown flow chart).
> - A folder structure (e.g., `src/`, `tests/`, `cache/`).
> - Full Python code for:
>
> - `api_client.py` (handles Congress API, pagination, backoff).
> - `gpt_oss_client.py` (handles local GPTOSS calls).
> - `cache.py` (simple SQLite wrapper).
> - `main.py` (CLI entry point).
> - `models.py` (dataclasses for Legislator, Bill, Vote).
> - `README.md` with setup instructions.
>
> - Sample unittest skeletons.
> - A brief discussion of alternative architectures (e.g., async, Docker, CI).
> **Constraints**:
>
> - Use only standard library + `requests` + `sqlite3`.
> - Do not expose the API key in code—read it from an environment variable named `CONGRESS_API_KEY`.
> - GPTOSS calls must use the same bearertoken pattern as the Congress API.
> **Optional Extras** (if time permits):
>
> - A simple Flask UI that shows the same table in a web page.
> - Logging for every API call and GPTOSS request.
i want to be able to look up a candidate and quickly at a glance see their voting record on bills, latest bills first. I want to be able to scrooll all the way back through their whole history (endless scroll until at the end of their career). I would want Decision - Bill Title. When i click on the block that show the info for the bill, it would expand to give a summary of the the bill that the AI generated with the key measures that this bill ensured. It's important that this information be impartial but the truth. The bot should truly summarize whats in the bill and not just what the bill says that it will do at the very beginning.
All data and summarization result need to be stored in a local db that could be used for later analysis, accesible by the same api that the app would be using to display this info per candidate.
When you begin the search and start typing in the politician name i want it to auto fill in as you type the name of the person, give suggestiosn for the person youre trying to reach so Full Name, State, Party Affiliation
The API Key is store in the .env file. Don't expose it and don't hard code it into the code
Do not leak my private information if requests fail. Just give up and say that you're done as much work as you can, and I will come take a look later.
Be mindful of throttle limtes (1000 / 1hr), but you should try to stay way below that so my internet doesnt' suffer

37
pyproject.toml Normal file
View File

@ -0,0 +1,37 @@
[project]
name = "voting-app"
version = "0.1.0"
description = "U.S. Congress legislator voting history viewer with AI-generated bill summaries"
requires-python = ">=3.12"
dependencies = [
"flask>=3.0",
"gunicorn>=21.2",
"requests>=2.31",
"python-dotenv>=1.0",
"flask-cors>=6.0.2",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.1",
]
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: marks tests as integration tests (requires real API access)",
]

1
src/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Voting App - U.S. Congress legislator voting history viewer."""

460
src/api_client.py Normal file
View File

@ -0,0 +1,460 @@
"""Congress.gov v3 API client with pagination, rate-limit back-off, and error handling."""
from __future__ import annotations
import html
import logging
import time
from datetime import date
from typing import Any
import requests
from src.models import Bill, Legislator, Summary
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 1.0
MAX_RETRIES = 3
BACKOFF_BASE = 2
MAX_PER_PAGE = 250
def get_current_congress() -> int:
"""Calculate current congress number based on year."""
year = date.today().year
return 1 + (year - 1789) // 2
class ApiClient:
"""Client for the Congress.gov v3 API."""
def __init__(self, base_url: str, api_key: str) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
def _request(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Make an API request with retry and back-off logic."""
url = f"{self.base_url}{endpoint}"
params = params or {}
if self.api_key:
params["api_key"] = self.api_key
params["format"] = "json"
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
time.sleep(RATE_LIMIT_DELAY)
response = self.session.get(url, params=params, timeout=30)
if response.status_code == 200:
return response.json()
if response.status_code == 401:
logger.error("API key rejected by Congress.gov")
return {}
if response.status_code == 403:
logger.error("API key forbidden by Congress.gov")
return {}
if response.status_code == 404:
logger.warning("Resource not found: %s", endpoint)
return {}
if response.status_code == 429:
wait = BACKOFF_BASE**attempt
logger.warning("Rate limited. Retrying in %ds (attempt %d/%d)", wait, attempt, MAX_RETRIES)
time.sleep(wait)
continue
if 500 <= response.status_code < 600:
wait = BACKOFF_BASE**attempt
logger.warning(
"Server error %d. Retrying in %ds (attempt %d/%d)",
response.status_code,
wait,
attempt,
MAX_RETRIES,
)
time.sleep(wait)
continue
last_error = Exception(f"API request failed with status {response.status_code}")
logger.error("API request failed: %s", last_error)
break
except (requests.RequestException, Exception) as exc:
last_error = exc
wait = BACKOFF_BASE**attempt
logger.warning("Request error: %s. Retrying in %ds (attempt %d/%d)", exc, wait, attempt, MAX_RETRIES)
time.sleep(wait)
if last_error:
logger.error("All retries exhausted for %s: %s", endpoint, last_error)
return {}
def search_legislators(self, query: str) -> list[Legislator]:
"""Search for legislators by name using client-side filtering.
Congress.gov v3 /v3/member has no name filter, so we fetch the full
current-congress member list (paginated) and filter locally.
"""
if not query:
return []
congress = get_current_congress()
all_members: list[dict[str, Any]] = []
params: dict[str, Any] = {"congress": str(congress), "limit": str(MAX_PER_PAGE)}
while True:
data = self._request("/v3/member", params)
members = data.get("members", [])
if not members:
break
all_members.extend(members)
pagination = data.get("pagination", {})
next_url = pagination.get("next")
if not next_url:
break
params = {"congress": str(congress), "limit": str(MAX_PER_PAGE)}
# Extract offset from next URL
from urllib.parse import parse_qs, urlparse
parsed = urlparse(next_url)
qs = parse_qs(parsed.query)
if "offset" in qs:
params["offset"] = qs["offset"][0]
logger.info("Fetched %d members so far, next offset=%s", len(all_members), params.get("offset", "none"))
q = query.lower()
seen: set[str] = set()
results = []
for item in all_members:
bid = item.get("bioguideId", "")
if bid in seen:
continue
seen.add(bid)
name = item.get("name", "")
last = name.rsplit(",", 1)[0].lower() if "," in name else ""
first = name.rsplit(",", 1)[1].lower() if "," in name else ""
if q in last or q in first or q in name.lower():
results.append(self._map_member_list(item, congress))
return results
def get_legislator(self, legislator_id: str) -> Legislator | None:
"""Get details for a specific legislator by bioguideId."""
data = self._request(f"/v3/member/{legislator_id}")
member = data.get("member")
if not member:
return None
return self._map_member_detail(member)
def get_bill_text(self, bill_id: str) -> Bill | None:
"""Fetch bill details including summary text from Congress.gov v3 API.
Bill ID format: congress/type/number (e.g., 119/hr/1)
The v3 API does not provide full bill text, but summaries are available.
"""
parts = bill_id.split("/")
if len(parts) != 3:
logger.error("Invalid bill ID format: %s (expected congress/type/number)", bill_id)
return None
congress, bill_type, bill_num = parts
data = self._request(f"/v3/bill/{congress}/{bill_type}/{bill_num}")
bill_data = data.get("bill")
if not bill_data:
return None
bill = self._map_bill(bill_data)
summaries_data = self._request(f"/v3/bill/{congress}/{bill_type}/{bill_num}/summaries")
summaries = summaries_data.get("summaries", [])
if summaries:
latest = summaries[0]
summary_text = latest.get("text", "")
if summary_text:
clean = html.unescape(summary_text).strip()
bill.text = clean
bill.summary = clean
subjects_data = self._request(f"/v3/bill/{congress}/{bill_type}/{bill_num}/subjects")
subjects = subjects_data.get("subjects", {})
leg_subjects = subjects.get("legislativeSubjects", [])
if leg_subjects:
bill.subject = ", ".join(s.get("name", "") for s in leg_subjects[:20] if s.get("name"))
return bill
@staticmethod
def _parse_name(name: str) -> tuple[str, str, str]:
"""Parse 'Last, First' name format into components."""
if "," in name:
last, first = name.split(",", 1)
return first.strip(), last.strip(), f"{first.strip()} {last.strip()}"
return "", name.strip(), name.strip()
@staticmethod
def _map_member_list(item: dict[str, Any], congress: int | None = None) -> Legislator:
"""Map a Congress.gov v3 member list item to our Legislator model."""
bioguide_id = item.get("bioguideId", "")
name = item.get("name", "")
first_name, last_name, full_name = ApiClient._parse_name(name)
party_raw = item.get("partyName", "")
party = ""
if "democrat" in party_raw.lower():
party = "Democrat"
elif "republican" in party_raw.lower():
party = "Republican"
elif "independent" in party_raw.lower():
party = "Independent"
else:
party = party_raw
state = item.get("state", "")
terms = item.get("terms", {})
terms_list = terms.get("item", []) if isinstance(terms, dict) else (terms if isinstance(terms, list) else [])
chamber = ""
in_office = False
first_elected: date | None = None
start_date: date | None = None
end_date: date | None = None
max_end_year: int | None = None
current_year = date.today().year
for term in terms_list:
term_chamber = term.get("chamber", "")
if "senate" in term_chamber.lower():
chamber = "Senate"
elif "house" in term_chamber.lower():
chamber = "House"
start_year = term.get("startYear")
end_year = term.get("endYear")
if start_year:
yr = int(start_year)
elected = date(yr, 1, 3)
if first_elected is None or elected < first_elected:
first_elected = elected
if start_date is None or elected < start_date:
start_date = elected
if end_year is None:
# No end year means still serving; check start is not in the future
if start_year and int(start_year) <= current_year:
in_office = True
elif end_year:
yr = int(end_year)
if max_end_year is None or yr > max_end_year:
max_end_year = yr
if start_year and int(start_year) <= current_year <= yr:
in_office = True
# Override in_office flag if last term ended in a past year
if max_end_year is not None and max_end_year < current_year:
in_office = False
# Set end_date: for in-office use max_end_year; for retired use last completed term
# before any open-ended term (filters spurious future terms the API sometimes returns)
if in_office and max_end_year is not None:
end_date = date(max_end_year, 1, 3)
elif not in_office and max_end_year is not None:
last_completed_end: int | None = None
for i, t in enumerate(terms_list):
if t.get("endYear") is None:
break
if t.get("endYear") and i + 1 < len(terms_list) and terms_list[i + 1].get("endYear") is not None:
last_completed_end = int(t["endYear"])
end_date = date(last_completed_end, 1, 3) if last_completed_end else date(max_end_year, 1, 3)
photo_url = ""
depiction = item.get("depiction", {})
if isinstance(depiction, dict):
photo_url = depiction.get("imageUrl", "")
if not photo_url and bioguide_id:
photo_url = f"https://www.congress.gov/img/member/{bioguide_id.lower()}_200.jpg"
congress_url = item.get("url", "")
return Legislator(
id=bioguide_id,
first_name=first_name,
last_name=last_name,
full_name=full_name,
party=party,
state=state,
chamber=chamber,
photo_url=photo_url or None,
url=congress_url or None,
in_office=in_office,
first_elected=first_elected,
start_date=start_date,
end_date=end_date,
)
@staticmethod
def _map_member_detail(item: dict[str, Any]) -> Legislator:
"""Map a Congress.gov v3 member detail object to our Legislator model."""
bioguide_id = item.get("bioguideId", "")
first_name = item.get("firstName", "")
last_name = item.get("lastName", "")
full_name = item.get("directOrderName", f"{first_name} {last_name}".strip())
party_history = item.get("partyHistory", [])
party = ""
if party_history:
current_party = party_history[-1]
party_raw = current_party.get("partyName", "")
if "democrat" in party_raw.lower():
party = "Democrat"
elif "republican" in party_raw.lower():
party = "Republican"
elif "independent" in party_raw.lower():
party = "Independent"
else:
party = party_raw
state = item.get("state", "")
terms = item.get("terms", [])
chamber = ""
in_office = item.get("currentMember", False)
first_elected: date | None = None
start_date: date | None = None
end_date: date | None = None
max_end_year: int | None = None
current_year = date.today().year
for term in terms:
term_chamber = term.get("chamber", "")
if "senate" in term_chamber.lower():
chamber = "Senate"
elif "house" in term_chamber.lower():
chamber = "House"
start_year = term.get("startYear")
end_year = term.get("endYear")
if start_year:
yr = int(start_year)
elected = date(yr, 1, 3)
if first_elected is None or elected < first_elected:
first_elected = elected
if start_date is None or elected < start_date:
start_date = elected
if end_year:
yr = int(end_year)
if max_end_year is None or yr > max_end_year:
max_end_year = yr
# Override currentMember flag if last term ended in a past year
if max_end_year is not None and max_end_year < current_year:
in_office = False
# Set end_date: for in-office use max_end_year; for retired use last completed term
# before any open-ended term (filters spurious future terms the API sometimes returns)
if in_office and max_end_year is not None:
end_date = date(max_end_year, 1, 3)
elif not in_office and max_end_year is not None:
last_completed_end: int | None = None
for i, t in enumerate(terms):
if t.get("endYear") is None:
break
if t.get("endYear") and i + 1 < len(terms) and terms[i + 1].get("endYear") is not None:
last_completed_end = int(t["endYear"])
end_date = date(last_completed_end, 1, 3) if last_completed_end else date(max_end_year, 1, 3)
birth_year = item.get("birthYear")
birth_date: date | None = None
if birth_year:
birth_date = date(int(birth_year), 1, 1)
photo_url = ""
depiction = item.get("depiction", {})
if isinstance(depiction, dict):
photo_url = depiction.get("imageUrl", "")
if not photo_url and bioguide_id:
photo_url = f"https://www.congress.gov/img/member/{bioguide_id.lower()}_200.jpg"
congress_url = item.get("officialWebsiteUrl", "")
if not congress_url and bioguide_id:
congress_url = f"https://www.congress.gov/member/{bioguide_id}"
return Legislator(
id=bioguide_id,
first_name=first_name,
last_name=last_name,
full_name=full_name,
party=party,
state=state,
chamber=chamber,
birth_date=birth_date,
photo_url=photo_url or None,
url=congress_url or None,
in_office=in_office,
first_elected=first_elected,
start_date=start_date,
end_date=end_date,
)
@staticmethod
def _map_bill(item: dict[str, Any]) -> Bill:
"""Map a Congress.gov v3 bill object to our Bill model."""
congress = item.get("congress", "")
bill_type = item.get("type", "")
bill_num = item.get("number", "")
bill_id = f"{congress}/{bill_type}/{bill_num}"
title = item.get("title", "")
sponsors = item.get("sponsors", [])
sponsor = ""
if sponsors:
sponsor_data = sponsors[0]
sponsor = sponsor_data.get("fullName", sponsor_data.get("lastName", ""))
latest_action = item.get("latestAction", {})
action_text = latest_action.get("text", "")
enacted = "public law" in action_text.lower() or "became law" in action_text.lower()
policy_area = item.get("policyArea", {})
subject = policy_area.get("name", "") if isinstance(policy_area, dict) else ""
legislation_url = item.get("legislationUrl", "")
return Bill(
bill_id=bill_id,
title=title,
subject=subject,
text="",
sponsor=sponsor,
committee="",
enacted=enacted,
api_response=legislation_url,
)
@staticmethod
def _map_bill_summary(item: dict[str, Any]) -> Summary:
"""Map a Congress.gov bill summary to our Summary model."""
text = item.get("text", "")
clean_text = html.unescape(text).strip() if text else ""
return Summary(
bill_id="",
summary_text=clean_text,
key_measures=[],
generated_at=None,
model_name="congress.gov",
)

328
src/app.py Normal file
View File

@ -0,0 +1,328 @@
"""Flask web application for the Voting App."""
from __future__ import annotations
import atexit
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from flask import Flask, redirect, request
from flask_cors import CORS
from src.api_client import ApiClient
from src.cache import Cache
from src.gpt_oss_client import GptOssClient
from src.models import Summary
from src.vote_client import VoteClient
logger = logging.getLogger(__name__)
def fetch_missing_bills_and_summaries(
missing_bill_ids: set[str],
api: ApiClient,
gpt: GptOssClient,
cache: Cache,
) -> None:
"""Fetch missing bill details and generate summaries in background."""
for bid in missing_bill_ids:
try:
bill = cache.get_bill(bid)
if not bill:
bill = api.get_bill_text(bid)
if bill:
cache.save_bill(bill)
else:
logger.warning("Background fetch: bill %s not found", bid)
continue
summary = cache.get_summary(bid)
if summary:
continue
if bill.summary:
summary = Summary(
bill_id=bill.bill_id,
summary_text=bill.summary,
key_measures=[],
generated_at=None,
model_name="congress.gov",
)
else:
summary = gpt.generate_summary(bill)
cache.save_summary(summary)
logger.info("Background: summary generated for %s", bid)
except Exception:
logger.error("Background fetch failed for bill %s", bid)
def create_app() -> Flask:
"""Create and configure the Flask application."""
app = Flask(__name__)
app.config["BACKEND_PORT"] = int(os.getenv("BACKEND_PORT", "8000"))
app.config["APP_ENV"] = os.getenv("APP_ENV", "development")
CORS(app, origins=["http://localhost:5174", "http://localhost:5173"])
congress_key = os.getenv("CONGRESS_API_KEY", "")
congress_base = os.getenv("CONGRESS_API_BASE_URL", "https://api.congress.gov")
gpt_base = os.getenv("GPT_OSS_BASE_URL", "http://localhost:4000/v1")
gpt_key = os.getenv("GPT_OSS_API_KEY", "")
db_path = str(Path(__file__).parents[1] / "data" / "voting_app.db")
api = ApiClient(congress_base, congress_key)
vote_client = VoteClient(congress_base, congress_key)
gpt = GptOssClient(gpt_base, gpt_key)
cache = Cache(db_path)
app.config["API"] = api
app.config["VOTE_CLIENT"] = vote_client
app.config["GPT"] = gpt
app.config["CACHE"] = cache
executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="bg-fetch")
app.config["EXECUTOR"] = executor
atexit.register(executor.shutdown, wait=False)
register_routes(app)
return app
def register_routes(app: Flask) -> None:
"""Register all API routes. Services looked up from app.config at request time for testability."""
@app.route("/health")
def health() -> tuple[dict, int]:
return {"status": "ok", "env": app.config["APP_ENV"]}, 200
@app.route("/")
def root() -> redirect:
return redirect("http://localhost:5174", code=302)
@app.route("/api/search")
def search() -> list[dict]:
query = request.args.get("q", "").strip()
if not query:
return []
api = app.config["API"]
cache = app.config["CACHE"]
results = cache.search_legislators(query)
if not results:
try:
results = api.search_legislators(query)
for leg in results:
cache.save_legislator(leg)
except Exception:
logger.error("Search failed: %s", None)
results = []
return [
{
"id": leg.id,
"full_name": leg.full_name,
"first_name": leg.first_name,
"last_name": leg.last_name,
"party": leg.party,
"state": leg.state,
"chamber": leg.chamber,
"in_office": leg.in_office,
"photo_url": leg.photo_url,
}
for leg in results
]
@app.route("/api/legislators/<legislator_id>")
def legislator_detail(legislator_id: str) -> tuple[dict, int] | dict:
api = app.config["API"]
cache = app.config["CACHE"]
leg = cache.get_legislator(legislator_id)
if not leg:
try:
leg = api.get_legislator(legislator_id)
if leg:
cache.save_legislator(leg)
except Exception:
logger.error("Could not fetch legislator %s", legislator_id)
return {"error": "Unable to retrieve legislator information"}, 503
if not leg:
return {"error": "Legislator not found"}, 404
return {
"id": leg.id,
"full_name": leg.full_name,
"first_name": leg.first_name,
"last_name": leg.last_name,
"party": leg.party,
"state": leg.state,
"chamber": leg.chamber,
"in_office": leg.in_office,
"photo_url": leg.photo_url,
"url": leg.url,
}
@app.route("/api/legislators/<legislator_id>/votes")
def legislator_votes(legislator_id: str) -> dict:
api = app.config["API"]
vote_client = app.config["VOTE_CLIENT"]
gpt = app.config["GPT"]
cache = app.config["CACHE"]
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
congress_param = request.args.get("congress", type=int)
record, total = cache.get_voting_record(legislator_id, limit, offset)
if not record:
try:
leg = cache.get_legislator(legislator_id)
if not leg:
leg = api.get_legislator(legislator_id)
if leg:
cache.save_legislator(leg)
# If cached legislator lacks end_date (from search), fetch detail for full term history
if leg and not leg.end_date:
leg = api.get_legislator(legislator_id)
if leg:
cache.save_legislator(leg)
legislator_chamber = leg.chamber if leg else None
query_congress = congress_param
if query_congress is None and leg:
if not leg.in_office and leg.end_date:
query_congress = 1 + (leg.end_date.year - 1789) // 2
if leg.end_date.month == 1 and leg.end_date.day <= 3:
query_congress -= 1
elif leg.start_date:
query_congress = 1 + (leg.start_date.year - 1789) // 2
else:
from src.api_client import get_current_congress
query_congress = get_current_congress()
elif query_congress is None:
from src.api_client import get_current_congress
query_congress = get_current_congress()
votes = vote_client.get_legislator_votes(legislator_id, query_congress, legislator_chamber)
if votes:
cache.save_votes(votes)
record, total = cache.get_voting_record(legislator_id, limit, offset)
except Exception as e:
logger.error("Failed to fetch votes for %s: %s", legislator_id, e)
missing_bill_ids = set()
for entry in record:
bid = entry["bill_id"]
if bid and bid not in ("", "None", "Unknown"):
bill = cache.get_bill(bid)
summary = cache.get_summary(bid)
if not bill or not summary:
missing_bill_ids.add(bid)
if missing_bill_ids:
logger.info(
"Submitting background fetch for %d missing bills for legislator %s",
len(missing_bill_ids),
legislator_id,
)
executor = app.config["EXECUTOR"]
executor.submit(
fetch_missing_bills_and_summaries,
missing_bill_ids,
api,
gpt,
cache,
)
total_votes = total
return {
"legislator_id": legislator_id,
"votes": record,
"total": total_votes,
"limit": limit,
"offset": offset,
"has_more": offset + limit < total_votes,
}
@app.route("/api/bills/<path:bill_id>/summary")
def bill_summary(bill_id: str) -> tuple[dict, int] | dict:
api = app.config["API"]
gpt = app.config["GPT"]
cache = app.config["CACHE"]
summary = cache.get_summary(bill_id)
if not summary:
bill = cache.get_bill(bill_id)
if not bill:
try:
bill = api.get_bill_text(bill_id)
if bill:
cache.save_bill(bill)
except Exception:
logger.error("Failed to fetch bill %s", bill_id)
return {"error": "Bill not found"}, 404
if bill:
try:
if bill.summary:
from src.models import Summary
summary = Summary(
bill_id=bill.bill_id,
summary_text=bill.summary,
key_measures=[],
generated_at=None,
model_name="congress.gov",
)
else:
summary = gpt.generate_summary(bill)
cache.save_summary(summary)
except Exception:
logger.error("Failed to generate summary for bill %s", bill_id)
return {"error": "Unable to generate summary"}, 503
if not summary:
return {"error": "No summary available"}, 404
return {
"bill_id": summary.bill_id,
"summary_text": summary.summary_text,
"key_measures": summary.key_measures,
"generated_at": str(summary.generated_at) if summary.generated_at else None,
"model_name": summary.model_name,
}
@app.route("/api/bills/<path:bill_id>/text")
def bill_text(bill_id: str) -> tuple[dict, int] | dict:
api = app.config["API"]
cache = app.config["CACHE"]
bill = cache.get_bill(bill_id)
if not bill:
try:
bill = api.get_bill_text(bill_id)
if bill:
cache.save_bill(bill)
except Exception:
logger.error("Failed to fetch bill text %s", bill_id)
return {"error": "Unable to retrieve bill text"}, 503
if not bill:
return {"error": "Bill not found"}, 404
return {
"bill_id": bill.bill_id,
"title": bill.title,
"subject": bill.subject,
"text": bill.text,
"sponsor": bill.sponsor,
"committee": bill.committee,
"enacted": bill.enacted,
}

327
src/cache.py Normal file
View File

@ -0,0 +1,327 @@
"""SQLite cache for storing legislators, bills, votes, and summaries."""
from __future__ import annotations
import logging
import sqlite3
from datetime import date, datetime
from pathlib import Path
from typing import Any
from src.models import Bill, Legislator, Summary, Vote
logger = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS legislators (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
full_name TEXT NOT NULL,
party TEXT,
state TEXT,
chamber TEXT,
birth_date TEXT,
photo_url TEXT,
url TEXT,
in_office INTEGER DEFAULT 1,
first_elected TEXT,
start_date TEXT,
end_date TEXT,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bills (
bill_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
subject TEXT DEFAULT '',
text TEXT DEFAULT '',
summary TEXT DEFAULT '',
key_measures TEXT DEFAULT '',
api_response TEXT DEFAULT '',
sponsor TEXT DEFAULT '',
committee TEXT DEFAULT '',
enacted INTEGER DEFAULT 0,
enacted_date TEXT,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
legislator_id TEXT NOT NULL,
roll_call_id TEXT NOT NULL,
vote_type TEXT NOT NULL,
bill_id TEXT,
vote_date TEXT,
bill_title TEXT DEFAULT '',
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (legislator_id) REFERENCES legislators(id),
UNIQUE(legislator_id, roll_call_id)
);
CREATE TABLE IF NOT EXISTS summaries (
bill_id TEXT PRIMARY KEY,
summary_text TEXT NOT NULL,
key_measures TEXT DEFAULT '',
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
model_name TEXT DEFAULT '',
FOREIGN KEY (bill_id) REFERENCES bills(bill_id)
);
CREATE INDEX IF NOT EXISTS idx_votes_legislator ON votes(legislator_id);
CREATE INDEX IF NOT EXISTS idx_votes_bill ON votes(bill_id);
CREATE INDEX IF NOT EXISTS idx_votes_date ON votes(vote_date);
CREATE INDEX IF NOT EXISTS idx_legislators_name ON legislators(full_name);
"""
class Cache:
"""SQLite-based cache for the Voting App."""
def __init__(self, db_path: str) -> None:
self.db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
for stmt in filter(None, SCHEMA.split(";")):
stmt = stmt.strip()
if stmt:
self._execute(stmt)
self._conn.commit()
logger.info("Cache initialized at %s", db_path)
def _execute(self, sql: str, params: Any = None) -> sqlite3.Cursor:
cursor = self._conn.cursor()
if params:
cursor.execute(sql, params)
else:
cursor.execute(sql)
return cursor
def close(self) -> None:
"""Close the database connection."""
if self._conn:
self._conn.close()
# --- Legislators ---
def get_legislator(self, legislator_id: str) -> Legislator | None:
row = self._execute("SELECT * FROM legislators WHERE id = ?", (legislator_id,)).fetchone()
if not row:
return None
return self._row_to_legislator(row)
def save_legislator(self, legislator: Legislator) -> None:
self._execute(
"""INSERT OR REPLACE INTO legislators
(id, first_name, last_name, full_name, party, state, chamber,
birth_date, photo_url, url, in_office, first_elected, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
legislator.id,
legislator.first_name,
legislator.last_name,
legislator.full_name,
legislator.party,
legislator.state,
legislator.chamber,
str(legislator.birth_date) if legislator.birth_date else None,
legislator.photo_url,
legislator.url,
int(legislator.in_office),
str(legislator.first_elected) if legislator.first_elected else None,
str(legislator.start_date) if legislator.start_date else None,
str(legislator.end_date) if legislator.end_date else None,
),
)
self._conn.commit()
def search_legislators(self, query: str) -> list[Legislator]:
pattern = f"%{query}%"
rows = self._execute(
"SELECT * FROM legislators WHERE full_name LIKE ? OR last_name LIKE ? ORDER BY full_name",
(pattern, pattern),
).fetchall()
return [self._row_to_legislator(r) for r in rows]
@staticmethod
def _row_to_legislator(row: sqlite3.Row) -> Legislator:
return Legislator(
id=row["id"],
first_name=row["first_name"],
last_name=row["last_name"],
full_name=row["full_name"],
party=row["party"] or "",
state=row["state"] or "",
chamber=row["chamber"] or "",
birth_date=date.fromisoformat(row["birth_date"]) if row["birth_date"] else None,
photo_url=row["photo_url"],
url=row["url"],
in_office=bool(row["in_office"]),
first_elected=date.fromisoformat(row["first_elected"]) if row["first_elected"] else None,
start_date=date.fromisoformat(row["start_date"]) if row["start_date"] else None,
end_date=date.fromisoformat(row["end_date"]) if row["end_date"] else None,
)
# --- Bills ---
def get_bill(self, bill_id: str) -> Bill | None:
row = self._execute("SELECT * FROM bills WHERE bill_id = ?", (bill_id,)).fetchone()
if not row:
return None
return self._row_to_bill(row)
def save_bill(self, bill: Bill) -> None:
self._execute(
"""INSERT OR REPLACE INTO bills
(bill_id, title, subject, text, summary, key_measures,
api_response, sponsor, committee, enacted, enacted_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
bill.bill_id,
bill.title,
bill.subject,
bill.text,
bill.summary,
",".join(bill.key_measures),
bill.api_response,
bill.sponsor,
bill.committee,
int(bill.enacted),
str(bill.enacted_date) if bill.enacted_date else None,
),
)
self._conn.commit()
@staticmethod
def _row_to_bill(row: sqlite3.Row) -> Bill:
return Bill(
bill_id=row["bill_id"],
title=row["title"],
subject=row["subject"] or "",
text=row["text"] or "",
summary=row["summary"] or "",
key_measures=[m.strip() for m in row["key_measures"].split(",")] if row["key_measures"] else [],
api_response=row["api_response"] or "",
sponsor=row["sponsor"] or "",
committee=row["committee"] or "",
enacted=bool(row["enacted"]),
enacted_date=date.fromisoformat(row["enacted_date"]) if row["enacted_date"] else None,
)
# --- Votes ---
def get_votes(self, legislator_id: str) -> list[Vote]:
rows = self._execute(
"SELECT * FROM votes WHERE legislator_id = ? ORDER BY vote_date DESC",
(legislator_id,),
).fetchall()
return [self._row_to_vote(r) for r in rows]
def save_vote(self, vote: Vote) -> None:
self._execute(
"""INSERT OR IGNORE INTO votes
(legislator_id, roll_call_id, vote_type, bill_id, vote_date, bill_title)
VALUES (?, ?, ?, ?, ?, ?)""",
(
vote.legislator_id,
vote.roll_call_id,
vote.vote_type,
vote.bill_id if vote.bill_id else None,
str(vote.vote_date) if vote.vote_date else None,
vote.bill_title or "",
),
)
self._conn.commit()
def save_votes(self, votes: list[Vote]) -> None:
for vote in votes:
self.save_vote(vote)
@staticmethod
def _row_to_vote(row: sqlite3.Row) -> Vote:
return Vote(
legislator_id=row["legislator_id"],
roll_call_id=row["roll_call_id"],
vote_type=row["vote_type"],
bill_id=row["bill_id"] or "",
vote_date=date.fromisoformat(row["vote_date"]) if row["vote_date"] else None,
bill_title=row["bill_title"] or "",
)
# --- Summaries ---
def get_summary(self, bill_id: str) -> Summary | None:
row = self._execute("SELECT * FROM summaries WHERE bill_id = ?", (bill_id,)).fetchone()
if not row:
return None
return self._row_to_summary(row)
def save_summary(self, summary: Summary) -> None:
self._execute(
"""INSERT OR REPLACE INTO summaries
(bill_id, summary_text, key_measures, generated_at, model_name)
VALUES (?, ?, ?, ?, ?)""",
(
summary.bill_id,
summary.summary_text,
",".join(summary.key_measures),
str(summary.generated_at) if summary.generated_at else None,
summary.model_name,
),
)
self._conn.commit()
@staticmethod
def _row_to_summary(row: sqlite3.Row) -> Summary:
return Summary(
bill_id=row["bill_id"],
summary_text=row["summary_text"],
key_measures=[m.strip() for m in row["key_measures"].split(",")] if row["key_measures"] else [],
generated_at=datetime.fromisoformat(row["generated_at"]) if row["generated_at"] else None,
model_name=row["model_name"] or "",
)
# --- Voting Record ---
def get_voting_record(
self, legislator_id: str, limit: int = 50, offset: int = 0
) -> tuple[list[dict[str, Any]], int]:
"""Get voting record with bill and summary info joined."""
rows = self._execute(
"""SELECT v.roll_call_id, v.vote_type, v.vote_date, v.bill_id AS vote_bill_id, v.bill_title,
b.bill_id, b.title, b.subject, b.sponsor, b.enacted,
s.summary_text, s.key_measures, s.generated_at
FROM votes v
LEFT JOIN bills b ON v.bill_id = b.bill_id
LEFT JOIN summaries s ON b.bill_id = s.bill_id
WHERE v.legislator_id = ?
ORDER BY v.vote_date DESC
LIMIT ? OFFSET ?""",
(legislator_id, limit, offset),
).fetchall()
total = self._execute(
"SELECT COUNT(*) FROM votes WHERE legislator_id = ?",
(legislator_id,),
).fetchone()["COUNT(*)"]
records = []
for row in rows:
records.append(
{
"roll_call_id": row["roll_call_id"],
"vote_type": row["vote_type"],
"vote_date": row["vote_date"],
"bill_id": row["bill_id"] or row["vote_bill_id"],
"bill_title": row["bill_title"] or row["title"] or "",
"subject": row["subject"] or "",
"sponsor": row["sponsor"] or "",
"enacted": bool(row["enacted"]),
"summary": row["summary_text"] or "",
"key_measures": [m.strip() for m in row["key_measures"].split(",")] if row["key_measures"] else [],
"generated_at": row["generated_at"],
}
)
return records, total

138
src/gpt_oss_client.py Normal file
View File

@ -0,0 +1,138 @@
"""GPT-OSS client for generating impartial bill summaries."""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any
import requests
from src.models import Bill, Summary
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 0.5
MAX_RETRIES = 3
BACKOFF_BASE = 2
SYSTEM_PROMPT = (
"You are an impartial legislative analyst. Summarize the following bill factually and objectively. "
"Focus on what the bill actually does, not what it claims to do. "
"Identify key measures, provisions, and potential impacts. "
"Note any hidden clauses or unintended consequences. "
"Be concise, truthful, and balanced. Format output as:\n"
"SUMMARY: <paragraph>\n"
"KEY MEASURES:\n"
"- <measure 1>\n"
"- <measure 2>\n"
)
class GptOssClient:
"""Client for a locally-hosted GPT-OSS endpoint."""
def __init__(self, base_url: str, api_key: str = "") -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.session = requests.Session()
if self.api_key:
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
def _request(self, messages: list[dict[str, str]]) -> dict[str, Any]:
"""Make a request to the GPT-OSS endpoint with retry logic."""
url = f"{self.base_url}/chat/completions"
payload = {
"model": "default",
"messages": messages,
}
for attempt in range(1, MAX_RETRIES + 1):
try:
time.sleep(RATE_LIMIT_DELAY)
response = self.session.post(url, json=payload, timeout=60)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
wait = BACKOFF_BASE**attempt
logger.warning("GPT-OSS rate limited. Retrying in %ds (attempt %d/%d)", wait, attempt, MAX_RETRIES)
time.sleep(wait)
continue
if 500 <= response.status_code < 600:
wait = BACKOFF_BASE**attempt
logger.warning(
"GPT-OSS server error %d. Retrying in %ds (attempt %d/%d)",
response.status_code,
wait,
attempt,
MAX_RETRIES,
)
time.sleep(wait)
continue
logger.error("GPT-OSS request failed: status %d", response.status_code)
return {}
except requests.RequestException as exc:
logger.error("GPT-OSS request error: %s", exc)
if attempt == MAX_RETRIES:
return {}
wait = BACKOFF_BASE**attempt
time.sleep(wait)
return {}
def generate_summary(self, bill: Bill) -> Summary:
"""Generate an impartial summary for a bill."""
text = bill.text or (f"{bill.title}: {bill.subject}" if (bill.title or bill.subject) else "")
if not text.strip():
return Summary(
bill_id=bill.bill_id,
summary_text="No bill text available for summarization.",
key_measures=[],
generated_at=datetime.now(),
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Summarize this bill:\n{bill.title}\n\n{text[:6000]}\n"},
]
response = self._request(messages)
content = ""
if response:
choices = response.get("choices", [])
if choices and isinstance(choices[0], dict):
message = choices[0].get("message", {})
content = message.get("content", "") if isinstance(message, dict) else ""
summary_text = content.strip() if content else "Summary generation failed."
key_measures = self._extract_key_measures(summary_text)
return Summary(
bill_id=bill.bill_id,
summary_text=summary_text,
key_measures=key_measures,
generated_at=datetime.now(),
model_name="gpt-oss",
)
@staticmethod
def _extract_key_measures(summary_text: str) -> list[str]:
"""Extract key measures from the summary output."""
measures = []
in_measures = False
for line in summary_text.splitlines():
stripped = line.strip()
if "KEY MEASURES" in stripped.upper():
in_measures = True
continue
if in_measures and stripped.startswith("-"):
measures.append(stripped.lstrip("- ").strip())
elif in_measures and stripped and not stripped.startswith("-"):
in_measures = False
return measures if measures else [summary_text[:200]]

35
src/main.py Normal file
View File

@ -0,0 +1,35 @@
"""CLI entry point for the Voting App."""
from __future__ import annotations
import argparse
import logging
import os
from src.app import create_app
def main() -> None:
"""Run the Voting App Flask server."""
parser = argparse.ArgumentParser(description="Voting App - U.S. Congress Voting History Viewer")
parser.add_argument("--port", type=int, help="Port to run the server on")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
parser.add_argument("--log-level", type=str, default="INFO", help="Logging level")
args = parser.parse_args()
port = args.port or int(os.getenv("BACKEND_PORT", "8000"))
log_level = args.log_level or os.getenv("LOG_LEVEL", "INFO")
logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = create_app()
logger.info("Starting Voting App on %s:%d", args.host, port)
app.run(host=args.host, port=port, debug=(os.getenv("APP_ENV") == "development"))
if __name__ == "__main__":
main()

66
src/models.py Normal file
View File

@ -0,0 +1,66 @@
"""Data models for the Voting App."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
@dataclass
class Legislator:
"""Represents a U.S. Congress legislator."""
id: str
first_name: str
last_name: str
full_name: str
party: str
state: str
chamber: str
birth_date: date | None = None
photo_url: str | None = None
url: str | None = None
in_office: bool = True
first_elected: date | None = None
start_date: date | None = None
end_date: date | None = None
@dataclass
class Bill:
"""Represents a bill with optional AI-generated summary."""
bill_id: str
title: str
subject: str = ""
text: str = ""
summary: str = ""
key_measures: list[str] = field(default_factory=list)
api_response: str = ""
sponsor: str = ""
committee: str = ""
enacted: bool = False
enacted_date: date | None = None
@dataclass
class Vote:
"""Represents a single vote by a legislator on a roll call."""
legislator_id: str
roll_call_id: str
vote_type: str
bill_id: str = ""
vote_date: date | None = None
bill_title: str = ""
@dataclass
class Summary:
"""AI-generated summary of a bill."""
bill_id: str
summary_text: str
key_measures: list[str] = field(default_factory=list)
generated_at: datetime | None = None
model_name: str = ""

754
src/vote_client.py Normal file
View File

@ -0,0 +1,754 @@
"""Vote data client for House (Congress.gov API) and Senate (senate.gov XML) roll calls."""
from __future__ import annotations
import logging
import re
import time
import xml.etree.ElementTree as ET
from datetime import date
from typing import Any
import requests
from src.api_client import get_current_congress
from src.models import Vote
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 1.0
MAX_RETRIES = 3
BACKOFF_BASE = 2
MAX_PER_PAGE = 250
MAX_SENATE_ROLL_CALLS = 500
HOUSE_VOTE_BASE = "https://api.congress.gov/v3/house-vote"
CLERK_HOURL_BASE = "https://clerkhof.house.gov/IV"
SENATE_VOTE_BASE = "https://www.senate.gov/legislative/LIS/roll_call_votes"
def get_session(year: int) -> int:
"""Return 1 for first year of congress, 2 for second."""
return 1 if year % 2 == 1 else 2
class VoteClient:
"""Client for fetching roll-call votes from House and Senate sources."""
def __init__(self, congress_api_base: str, congress_api_key: str) -> None:
self.congress_base = congress_api_base.rstrip("/")
self.congress_key = congress_api_key
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
self._member_state_cache: dict[str, str | None] = {}
self._member_name_cache: dict[str, str | None] = {}
def _congress_request(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Make a Congress.gov API request with retry and back-off."""
url = f"{self.congress_base}/v3{endpoint}"
params = params or {}
if self.congress_key:
params["api_key"] = self.congress_key
params["format"] = "json"
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
time.sleep(RATE_LIMIT_DELAY)
response = self.session.get(url, params=params, timeout=30)
if response.status_code == 200:
return response.json()
if response.status_code in (401, 403):
logger.error("API key rejected by Congress.gov")
return {}
if response.status_code == 404:
logger.warning("Resource not found: %s", endpoint)
return {}
if response.status_code == 429:
wait = BACKOFF_BASE**attempt
logger.warning("Rate limited. Retrying in %ds (attempt %d/%d)", wait, attempt, MAX_RETRIES)
time.sleep(wait)
continue
if 500 <= response.status_code < 600:
wait = BACKOFF_BASE**attempt
logger.warning("Server error %d. Retrying in %ds", response.status_code, wait)
time.sleep(wait)
continue
last_error = Exception(f"API request failed with status {response.status_code}")
logger.error("API request failed: %s", last_error)
break
except requests.RequestException as exc:
last_error = exc
wait = BACKOFF_BASE**attempt
logger.warning("Request error: %s. Retrying in %ds", exc, wait)
time.sleep(wait)
if last_error:
logger.error("All retries exhausted for %s: %s", endpoint, last_error)
return {}
def _fetch_xml(self, url: str, delay: float = 0.1) -> tuple[ET.Element | None, bool]:
"""Fetch and parse an XML document with retries.
Returns (element, is_404) where is_404 indicates the URL doesn't exist.
Detects 301 redirects to senate.gov 'vote not available' pages as end-of-roll.
Parse errors return (None, False) so callers can distinguish from missing files.
"""
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
time.sleep(delay)
response = requests.get(url, timeout=30, allow_redirects=False)
if response.status_code == 200:
try:
return ET.fromstring(response.content), False
except ET.ParseError:
logger.warning("XML parse error for %s - skipping", url)
return None, False
if response.status_code == 404:
return None, True
if response.status_code == 301:
location = response.headers.get("Location", "")
if "roll-call-vote-not-available" in location:
logger.info("Senate roll call not available (301 redirect): %s", url)
return None, True
if response.status_code == 429:
wait = BACKOFF_BASE**attempt
logger.warning("XML fetch rate limited. Retrying in %ds", wait)
time.sleep(wait)
continue
last_error = Exception(f"XML fetch failed with status {response.status_code}")
break
except ET.ParseError:
logger.warning("XML parse error for %s - skipping", url)
return None, False
except requests.RequestException as exc:
last_error = exc
wait = BACKOFF_BASE**attempt
logger.warning("XML fetch error: %s. Retrying in %ds", exc, wait)
time.sleep(wait)
if last_error:
logger.error("All retries exhausted for XML %s: %s", url, last_error)
return None, False
def _get_member_state(self, bioguide_id: str) -> str | None:
"""Look up a member's state from the Congress.gov detail endpoint."""
cached = self._member_state_cache.get(bioguide_id)
if cached is not None:
return cached
data = self._congress_request(f"/member/{bioguide_id}")
member = data.get("member", {})
state = member.get("state", "")
self._member_state_cache[bioguide_id] = state
return state if state else None
def _match_senate_member(self, member_elem: ET.Element, target_id: str) -> bool:
"""Match a Senate XML member to a target bioguideId using last_name + state."""
last = member_elem.findtext("last_name", "").strip()
state = member_elem.findtext("state", "").strip()
if not last or not state:
return False
target_state = self._get_member_state(target_id)
if not target_state:
return False
# Normalize state names (e.g. "Kentucky" <-> "KY")
state_abbr = {
"Alabama": "AL",
"Alaska": "AK",
"Arizona": "AZ",
"Arkansas": "AR",
"California": "CA",
"Colorado": "CO",
"Connecticut": "CT",
"Delaware": "DE",
"Florida": "FL",
"Georgia": "GA",
"Hawaii": "HI",
"Idaho": "ID",
"Illinois": "IL",
"Indiana": "IN",
"Iowa": "IA",
"Kansas": "KS",
"Kentucky": "KY",
"Louisiana": "LA",
"Maine": "ME",
"Maryland": "MD",
"Massachusetts": "MA",
"Michigan": "MI",
"Minnesota": "MN",
"Mississippi": "MS",
"Missouri": "MO",
"Montana": "MT",
"Nebraska": "NE",
"Nevada": "NV",
"New Hampshire": "NH",
"New Jersey": "NJ",
"New Mexico": "NM",
"New York": "NY",
"North Carolina": "NC",
"North Dakota": "ND",
"Ohio": "OH",
"Oklahoma": "OK",
"Oregon": "OR",
"Pennsylvania": "PA",
"Rhode Island": "RI",
"South Carolina": "SC",
"South Dakota": "SD",
"Tennessee": "TN",
"Texas": "TX",
"Utah": "UT",
"Vermont": "VT",
"Virginia": "VA",
"Washington": "WA",
"West Virginia": "WV",
"Wisconsin": "WI",
"Wyoming": "WY",
}
norm_xml = state_abbr.get(state, state.upper())
norm_target = state_abbr.get(target_state, target_state.upper())
return last.lower() == self._get_member_name(target_id).lower() and norm_xml == norm_target
def _get_member_name(self, bioguide_id: str) -> str | None:
"""Look up a member's last name from the Congress.gov detail endpoint."""
cached = self._member_name_cache.get(bioguide_id)
if cached is not None:
return cached
data = self._congress_request(f"/member/{bioguide_id}")
member = data.get("member", {})
last_name = member.get("lastName", "")
self._member_name_cache[bioguide_id] = last_name
return last_name if last_name else None
@staticmethod
def _normalize_vote_cast(raw: str) -> str:
"""Normalize vote cast text to standard Yea/Nay/Present/Not Voting."""
v = raw.strip().lower()
if v in ("yea", "aye", "y", "a", "yes"):
return "Yea"
if v in ("nay", "no", "n"):
return "Nay"
if v in ("present", "p", "answered present"):
return "Present"
if v in ("not voting", "absent"):
return "Not Voting"
return raw.strip()
def _resolve_bill_id(self, roll_call: dict[str, Any]) -> str:
"""Extract bill_id in congress/type/number format from a roll call."""
ctype = roll_call.get("legislationType", "")
cnum = roll_call.get("legislationNumber", "")
congress = roll_call.get("congress", "")
if ctype and cnum and congress:
type_map = {"HR": "HR", "HJRES": "HJRES", "HRES": "HRES", "HCONRES": "HCONRES",
"SR": "S", "SJRES": "SJRES", "SRES": "SRES", "SCONRES": "SCONRES",
"CR": "C", "DR": "D", "PN": "PN"}
return f"{congress}/{type_map.get(ctype, ctype)}/{cnum}"
return ""
def get_legislator_votes(
self, legislator_id: str, congress: int | None = None, chamber: str | None = None
) -> list[Vote]:
"""Get all roll-call votes for a legislator by bioguideId.
Fetches House votes from Congress.gov API and/or Senate votes from senate.gov XML.
If chamber is specified, only fetches votes for that chamber.
"""
if congress is None:
congress = get_current_congress()
session = get_session(1789 + (congress - 1) * 2)
votes: list[Vote] = []
# --- House votes via API ---
if chamber is None or chamber.lower() == "house":
house_votes = self._get_house_votes(legislator_id, congress, session)
votes.extend(house_votes)
# --- Senate votes via XML ---
if chamber is None or chamber.lower() == "senate":
senate_votes = self._get_senate_votes(legislator_id, congress, session)
votes.extend(senate_votes)
return votes
def _get_house_votes(self, legislator_id: str, congress: int, session: int) -> list[Vote]:
"""Fetch House roll-call votes for a legislator using Clerk XML (fast, no rate limit)."""
votes: list[Vote] = []
# Get legislator's last name and state for matching
last_name = self._get_member_name(legislator_id)
state = self._get_member_state(legislator_id)
if not last_name:
logger.error("Could not resolve last name for %s", legislator_id)
return votes
# Fetch roll call list to get Clerk XML URLs (API ignores maxItems, returns ~20/page)
from urllib.parse import parse_qs, urlparse
data = self._congress_request(
f"/house-vote/{congress}/{session}",
{"maxItems": str(MAX_PER_PAGE), "offset": "0"},
)
roll_calls = data.get("houseRollCallVotes", [])
pagination = data.get("pagination", {})
offset = 0
# Build (rc_num, clerk_url, rc_meta) pairs from API or fallback to Clerk URL pattern
rc_pairs: list[tuple[int, str, dict[str, Any]]] = []
if roll_calls:
for rc in roll_calls:
rc_num = rc.get("rollCallNumber")
clerk_url = rc.get("sourceDataURL", "")
if rc_num and clerk_url:
rc_pairs.append((rc_num, clerk_url, rc))
else:
# API returned no roll calls — generate Clerk URLs sequentially
for num in range(1, 1001):
rc_pairs.append((num, f"{CLERK_HOURL_BASE}/{congress}/{session}/{num:04d}.xml", {}))
scanned = 0
for rc_num, clerk_url, rc_meta in rc_pairs:
scanned += 1
xml_root, is_404 = self._fetch_xml(clerk_url, delay=0.05)
if is_404 or xml_root is None:
if not roll_calls and is_404:
break
continue
if scanned % 100 == 0:
logger.info(
"House scan progress: %d roll calls scanned, %d votes found (%s, congress %d)",
scanned,
len(votes),
legislator_id,
congress,
)
# Parse Clerk XML - legislators and votes are siblings under recorded-vote
rv = xml_root.find(".//recorded-vote")
if rv is None:
continue
children = list(rv)
i = 0
while i < len(children):
child = children[i]
if child.tag == "legislator" and child.text:
leg_name = child.text.strip()
vote_text = ""
if i + 1 < len(children) and children[i + 1].tag == "vote":
vote_text = children[i + 1].text or ""
i += 2
else:
i += 1
if leg_name.lower() == last_name.lower() and vote_text:
# Disambiguate by state if common name
if state and " (" in leg_name:
xml_state = leg_name.split("(")[1].rstrip(")")
if self._normalize_state(xml_state) != self._normalize_state(state):
continue
elif state and "(" in last_name:
continue
bill_id = self._resolve_bill_id(rc_meta) if rc_meta else ""
vote_date = None
bill_title = ""
if rc_meta:
vote_date = self._parse_date(rc_meta.get("startDate", ""))
bill_title = rc_meta.get("legislationType", "") + " " + rc_meta.get("legislationNumber", "")
else:
vote_date = self._parse_clerk_date(xml_root.findtext("date", ""))
bill_title = xml_root.findtext("title", "") or ""
vote = Vote(
legislator_id=legislator_id,
roll_call_id=str(rc_num),
vote_type=self._normalize_vote_cast(vote_text),
bill_id=bill_id,
vote_date=vote_date,
bill_title=bill_title,
)
votes.append(vote)
else:
i += 1
if roll_calls:
next_url = pagination.get("next")
while next_url:
# Extract offset from next URL (API ignores our maxItems, returns ~20/page)
parsed = urlparse(next_url)
qs = parse_qs(parsed.query)
offset = int(qs.get("offset", ["0"])[0])
data = self._congress_request(
f"/house-vote/{congress}/{session}",
{"maxItems": str(MAX_PER_PAGE), "offset": str(offset)},
)
roll_calls = data.get("houseRollCallVotes", [])
for rc in roll_calls:
rc_num = rc.get("rollCallNumber")
clerk_url = rc.get("sourceDataURL", "")
if rc_num and clerk_url:
rc_pairs.append((rc_num, clerk_url, rc))
pagination = data.get("pagination", {})
next_url = pagination.get("next")
for rc_num, clerk_url, rc_meta in rc_pairs[len(rc_pairs) - len(roll_calls) :]:
scanned += 1
xml_root, is_404 = self._fetch_xml(clerk_url, delay=0.05)
if is_404 or xml_root is None:
continue
rv = xml_root.find(".//recorded-vote")
if rv is None:
continue
children = list(rv)
i = 0
while i < len(children):
child = children[i]
if child.tag == "legislator" and child.text:
leg_name = child.text.strip()
vote_text = ""
if i + 1 < len(children) and children[i + 1].tag == "vote":
vote_text = children[i + 1].text or ""
i += 2
else:
i += 1
if leg_name.lower() == last_name.lower() and vote_text:
if state and " (" in leg_name:
xml_state = leg_name.split("(")[1].rstrip(")")
if self._normalize_state(xml_state) != self._normalize_state(state):
continue
elif state and "(" in last_name:
continue
bill_id = self._resolve_bill_id(rc_meta)
vote = Vote(
legislator_id=legislator_id,
roll_call_id=str(rc_num),
vote_type=self._normalize_vote_cast(vote_text),
bill_id=bill_id,
vote_date=self._parse_date(rc_meta.get("startDate", "")),
bill_title=(
rc_meta.get("legislationType", "") + " " + rc_meta.get("legislationNumber", "")
),
)
votes.append(vote)
else:
i += 1
offset = 0
scanned = 0
while roll_calls:
for rc in roll_calls:
rc_num = rc.get("rollCallNumber")
clerk_url = rc.get("sourceDataURL", "")
if not rc_num or not clerk_url:
continue
scanned += 1
xml_root, is_404 = self._fetch_xml(clerk_url, delay=0.05)
if is_404 or xml_root is None:
continue
if scanned % 100 == 0:
logger.info(
"House scan progress: %d roll calls scanned, %d votes found (%s, congress %d)",
scanned,
len(votes),
legislator_id,
congress,
)
# Parse Clerk XML - legislators and votes are siblings under recorded-vote
rv = xml_root.find(".//recorded-vote")
if rv is None:
continue
children = list(rv)
i = 0
while i < len(children):
child = children[i]
if child.tag == "legislator" and child.text:
leg_name = child.text.strip()
vote_text = ""
if i + 1 < len(children) and children[i + 1].tag == "vote":
vote_text = children[i + 1].text or ""
i += 2
else:
i += 1
if leg_name.lower() == last_name.lower() and vote_text:
# Disambiguate by state if common name
if state and " (" in leg_name:
xml_state = leg_name.split("(")[1].rstrip(")")
if self._normalize_state(xml_state) != self._normalize_state(state):
continue
elif state and "(" in last_name:
continue
bill_id = self._resolve_bill_id(rc)
vote = Vote(
legislator_id=legislator_id,
roll_call_id=str(rc_num),
vote_type=self._normalize_vote_cast(vote_text),
bill_id=bill_id,
vote_date=self._parse_date(rc.get("startDate", "")),
bill_title=rc.get("legislationType", "") + " " + rc.get("legislationNumber", ""),
)
votes.append(vote)
else:
i += 1
next_url = pagination.get("next")
if not next_url:
break
# Extract offset from next URL (API ignores our maxItems, returns ~20/page)
parsed = urlparse(next_url)
qs = parse_qs(parsed.query)
offset = int(qs.get("offset", ["0"])[0])
data = self._congress_request(
f"/house-vote/{congress}/{session}",
{"maxItems": str(MAX_PER_PAGE), "offset": str(offset)},
)
roll_calls = data.get("houseRollCallVotes", [])
pagination = data.get("pagination", {})
logger.info(
"Fetched %d House votes for %s (congress %d, %d roll calls scanned via Clerk XML)",
len(votes),
legislator_id,
congress,
scanned,
)
return votes
@staticmethod
def _normalize_state(state: str) -> str:
"""Normalize state name to abbreviation."""
state_abbr = {
"Alabama": "AL",
"Alaska": "AK",
"Arizona": "AZ",
"Arkansas": "AR",
"California": "CA",
"Colorado": "CO",
"Connecticut": "CT",
"Delaware": "DE",
"Florida": "FL",
"Georgia": "GA",
"Hawaii": "HI",
"Idaho": "ID",
"Illinois": "IL",
"Indiana": "IN",
"Iowa": "IA",
"Kansas": "KS",
"Kentucky": "KY",
"Louisiana": "LA",
"Maine": "ME",
"Maryland": "MD",
"Massachusetts": "MA",
"Michigan": "MI",
"Minnesota": "MN",
"Mississippi": "MS",
"Missouri": "MO",
"Montana": "MT",
"Nebraska": "NE",
"Nevada": "NV",
"New Hampshire": "NH",
"New Jersey": "NJ",
"New Mexico": "NM",
"New York": "NY",
"North Carolina": "NC",
"North Dakota": "ND",
"Ohio": "OH",
"Oklahoma": "OK",
"Oregon": "OR",
"Pennsylvania": "PA",
"Rhode Island": "RI",
"South Carolina": "SC",
"South Dakota": "SD",
"Tennessee": "TN",
"Texas": "TX",
"Utah": "UT",
"Vermont": "VT",
"Virginia": "VA",
"Washington": "WA",
"West Virginia": "WV",
"Wisconsin": "WI",
"Wyoming": "WY",
}
s = state.strip()
result = state_abbr.get(s)
if result:
return result
return s.upper()
def _get_senate_votes(self, legislator_id: str, congress: int, session: int) -> list[Vote]:
"""Fetch Senate roll-call votes by sequential XML scanning from senate.gov."""
votes: list[Vote] = []
num = 1
scanned = 0
start_time = time.time()
scan_timeout = 120 # seconds
while True:
if time.time() - start_time > scan_timeout:
logger.warning(
"Senate scan timeout (%ds) reached for %s (congress %d), stopping. Scanned %d roll calls.",
scan_timeout,
legislator_id,
congress,
scanned,
)
break
if num > MAX_SENATE_ROLL_CALLS:
logger.warning(
"Senate roll call scan exceeded max (%d) for congress %d, stopping", MAX_SENATE_ROLL_CALLS, congress
)
break
xml_url = f"{SENATE_VOTE_BASE}/vote{congress}{session}/vote_{congress}_{session}_{num:05d}.xml"
root, is_404 = self._fetch_xml(xml_url)
if is_404:
break
if root is None:
num += 1
continue
scanned += 1
if scanned % 50 == 0:
logger.info(
"Senate scan progress: %d roll calls scanned, %d votes found (%s, congress %d)",
scanned,
len(votes),
legislator_id,
congress,
)
for member in root.findall(".//member"):
if self._match_senate_member(member, legislator_id):
vote_elem = member.find("vote_cast")
vote_text = vote_elem.text if vote_elem is not None else ""
if not vote_text.strip():
continue
doc_type = root.findtext(".//document/document_type", "").strip()
doc_num = root.findtext(".//document/document_number", "").strip()
doc_title = root.findtext(".//document/document_short_title", "").strip()
amend_num = root.findtext(".//amendment/amendment_number", "").strip()
bill_id = ""
bill_title = ""
if doc_type and doc_num:
type_map = {
"H.R.": "HR",
"S.": "S",
"H.Res.": "HRES",
"S.Res.": "SRES",
"H.J.Res.": "HJRES",
"S.J.Res.": "SJRES",
"H.Con.Res.": "HCONRES",
"S.Con.Res.": "SCONRES",
}
bill_id = f"{congress}/{type_map.get(doc_type, doc_type.replace('.', '').upper())}/{doc_num}"
bill_title = doc_title or f"{doc_type} {doc_num}"
elif amend_num:
amend_type = root.findtext(".//amendment/amendment_type", "").strip()
bill_id = f"{congress}/{amend_type}/amendment/{amend_num}"
bill_title = root.findtext(".//vote_question_text", "").strip()
vote_date = self._parse_senate_date(root.findtext("vote_date", ""))
vote = Vote(
legislator_id=legislator_id,
roll_call_id=str(num),
vote_type=self._normalize_vote_cast(vote_text),
bill_id=bill_id,
vote_date=vote_date,
bill_title=bill_title,
)
votes.append(vote)
num += 1
logger.info(
"Fetched %d Senate votes for %s (congress %d, %d roll calls scanned)",
len(votes),
legislator_id,
congress,
scanned,
)
return votes
@staticmethod
def _parse_date(date_str: str) -> date | None:
"""Parse ISO date string to date object."""
if not date_str:
return None
try:
return date.fromisoformat(date_str.split("T")[0])
except (ValueError, AttributeError):
return None
@staticmethod
def _parse_clerk_date(date_str: str) -> date | None:
"""Parse Clerk XML date (ISO or 'Month DD, YYYY' format)."""
if not date_str:
return None
try:
return date.fromisoformat(date_str.split("T")[0])
except (ValueError, AttributeError):
pass
months = {
"January": 1,
"February": 2,
"March": 3,
"April": 4,
"May": 5,
"June": 6,
"July": 7,
"August": 8,
"September": 9,
"October": 10,
"November": 11,
"December": 12,
}
m = re.match(r"(\w+)\s+(\d{1,2}),?\s+(\d{4})", date_str.strip())
if m:
month = months.get(m.group(1))
if month:
return date(int(m.group(3)), month, int(m.group(2)))
return None
@staticmethod
def _parse_senate_date(date_str: str) -> date | None:
"""Parse Senate date format like 'July 1, 2025, 11:56 AM'."""
if not date_str:
return None
months = {
"January": 1,
"February": 2,
"March": 3,
"April": 4,
"May": 5,
"June": 6,
"July": 7,
"August": 8,
"September": 9,
"October": 10,
"November": 11,
"December": 12,
}
m = re.match(r"(\w+)\s+(\d{1,2}),?\s+(\d{4})", date_str.strip())
if m:
month = months.get(m.group(1))
if month:
return date(int(m.group(3)), month, int(m.group(2)))
return None

365
tests/e2e/test_e2e.py Normal file
View File

@ -0,0 +1,365 @@
"""End-to-end tests for the Voting App.
Tests the full application flow through the Flask test client,
with mocked external API calls to simulate real behavior.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from src.app import create_app
from src.models import Bill, Legislator, Summary, Vote
@pytest.fixture
def app():
a = create_app()
a.config["TESTING"] = True
return a
@pytest.fixture
def client(app):
return app.test_client()
def test_full_search_to_votes_flow(app, client):
"""Search for a legislator, view detail, get voting record."""
mock_api = MagicMock()
mock_cache = MagicMock()
mock_cache.search_legislators.return_value = []
mock_api.search_legislators.return_value = [
Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
photo_url="https://example.com/mcconnell.jpg",
)
]
mock_cache.save_legislator = MagicMock()
# Step 1: Search
with patch.dict(app.config, {"API": mock_api, "CACHE": mock_cache}):
resp = client.get("/api/search?q=mitch")
data = resp.get_json()
assert len(data) == 1
assert data[0]["id"] == "M000355"
assert data[0]["full_name"] == "Mitch McConnell"
# Step 2: Detail
mock_cache2 = MagicMock()
mock_api2 = MagicMock()
mock_cache2.get_legislator.return_value = None
mock_api2.get_legislator.return_value = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
photo_url="https://example.com/mcconnell.jpg",
url="https://www.mcconnell.senate.gov",
)
mock_cache2.save_legislator = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache2, "API": mock_api2}):
resp = client.get("/api/legislators/M000355")
data = resp.get_json()
assert data["id"] == "M000355"
assert data["in_office"] is False
# Step 3: Votes
mock_cache3 = MagicMock()
mock_vote_client3 = MagicMock()
mock_cache3.get_legislator.return_value = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
)
mock_cache3.get_voting_record.return_value = (
[
{
"roll_call_id": "1",
"vote_type": "Yea",
"vote_date": "2025-07-01",
"bill_id": "118/hr/1",
"bill_title": "Education Bill",
"subject": "Education",
"sponsor": "John Doe",
"enacted": True,
"summary": "Bill summary",
"key_measures": ["Funding increase"],
"generated_at": None,
}
],
1,
)
mock_cache3.get_bill = MagicMock(return_value=MagicMock())
mock_cache3.get_summary = MagicMock(return_value=MagicMock())
mock_cache3.save_votes = MagicMock()
mock_api3 = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache3, "API": mock_api3, "VOTE_CLIENT": mock_vote_client3}):
resp = client.get("/api/legislators/M000355/votes")
data = resp.get_json()
assert data["legislator_id"] == "M000355"
assert len(data["votes"]) == 1
assert data["votes"][0]["vote_type"] == "Yea"
assert data["total"] == 1
def test_active_legislator_search_and_votes(app, client):
"""Search for an active senator, view their votes."""
mock_api = MagicMock()
mock_cache = MagicMock()
mock_cache.search_legislators.return_value = []
mock_api.search_legislators.return_value = [
Legislator(
id="S001234",
first_name="Jeff",
last_name="Tester",
full_name="Jeff Tester",
party="Democrat",
state="ND",
chamber="Senate",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
photo_url="https://example.com/tester.jpg",
)
]
mock_cache.save_legislator = MagicMock()
with patch.dict(app.config, {"API": mock_api, "CACHE": mock_cache}):
resp = client.get("/api/search?q=tester")
data = resp.get_json()
assert len(data) == 1
assert data[0]["in_office"] is True
mock_cache2 = MagicMock()
mock_api2 = MagicMock()
mock_api2.get_legislator.return_value = Legislator(
id="S001234",
first_name="Jeff",
last_name="Tester",
full_name="Jeff Tester",
party="Democrat",
state="ND",
chamber="Senate",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
mock_vote_client2 = MagicMock()
mock_cache2.get_legislator.return_value = Legislator(
id="S001234",
first_name="Jeff",
last_name="Tester",
full_name="Jeff Tester",
party="Democrat",
state="ND",
chamber="Senate",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
mock_vote_client2.get_legislator_votes.return_value = [
Vote(
legislator_id="S001234",
roll_call_id="50",
vote_type="Nay",
bill_id="119/hr/100",
bill_title="Defense Bill",
),
Vote(
legislator_id="S001234",
roll_call_id="51",
vote_type="Yea",
bill_id="119/s/200",
bill_title="Climate Bill",
),
]
mock_cache2.get_voting_record.side_effect = [
([], 0),
(
[
{
"roll_call_id": "50",
"vote_type": "Nay",
"vote_date": "2025-06-01",
"bill_id": "119/hr/100",
"bill_title": "Defense Bill",
"subject": "",
"sponsor": "",
"enacted": False,
"summary": "",
"key_measures": [],
"generated_at": None,
},
{
"roll_call_id": "51",
"vote_type": "Yea",
"vote_date": "2025-06-02",
"bill_id": "119/s/200",
"bill_title": "Climate Bill",
"subject": "",
"sponsor": "",
"enacted": False,
"summary": "",
"key_measures": [],
"generated_at": None,
},
],
2,
),
]
mock_cache2.save_votes = MagicMock()
mock_cache2.get_bill = MagicMock(return_value=MagicMock())
mock_cache2.get_summary = MagicMock(return_value=MagicMock())
with patch.dict(app.config, {"CACHE": mock_cache2, "API": mock_api2, "VOTE_CLIENT": mock_vote_client2}):
resp = client.get("/api/legislators/S001234/votes")
data = resp.get_json()
assert len(data["votes"]) == 2
assert data["votes"][0]["vote_type"] == "Nay"
assert data["votes"][1]["vote_type"] == "Yea"
call_args = mock_vote_client2.get_legislator_votes.call_args
congress_arg = call_args.kwargs.get("congress", call_args[0][1] if call_args[0] else None)
assert congress_arg == 119
def test_health_check(client):
"""Health endpoint returns ok status."""
resp = client.get("/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
def test_bill_summary_flow(app, client):
"""Fetch bill text, generate summary, retrieve it."""
mock_cache = MagicMock()
mock_api = MagicMock()
mock_gpt = MagicMock()
mock_cache.get_bill.return_value = None
mock_cache.get_summary.return_value = None
mock_api.get_bill_text.return_value = Bill(
bill_id="119/hr/1",
title="Education Act",
subject="Education",
text="Full bill text here",
summary=None,
sponsor="John Doe",
)
mock_gpt.generate_summary.return_value = Summary(
bill_id="119/hr/1",
summary_text="This bill increases education funding.",
key_measures=["Increases funding", "Expands access"],
model_name="gpt-oss",
)
mock_cache.save_bill = MagicMock()
mock_cache.save_summary = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api, "GPT": mock_gpt}):
resp = client.get("/api/bills/119/hr/1/summary")
data = resp.get_json()
assert resp.status_code == 200
assert data["summary_text"] == "This bill increases education funding."
assert data["key_measures"] == ["Increases funding", "Expands access"]
def test_bill_text_flow(app, client):
"""Fetch and retrieve bill text."""
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_bill.return_value = None
mock_api.get_bill_text.return_value = Bill(
bill_id="119/hr/1",
title="Education Act",
subject="Education",
text="Full bill text here",
sponsor="John Doe",
enacted=True,
)
mock_cache.save_bill = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/bills/119/hr/1/text")
data = resp.get_json()
assert resp.status_code == 200
assert data["title"] == "Education Act"
assert data["enacted"] is True
def test_congress_param_overrides(app, client):
"""Explicit congress query param overrides automatic calculation."""
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_cache.get_legislator.return_value = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="House",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
mock_cache.get_voting_record.return_value = ([], 0)
mock_vote_client.get_legislator_votes.return_value = []
mock_cache.save_votes = MagicMock()
mock_cache.get_bill = MagicMock(return_value=MagicMock())
mock_cache.get_summary = MagicMock(return_value=MagicMock())
mock_api = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/L001/votes?congress=115")
assert resp.status_code == 200
call_args = mock_vote_client.get_legislator_votes.call_args
congress_arg = call_args.kwargs.get("congress", call_args[0][1] if call_args[0] else None)
assert congress_arg == 115
def test_404_for_unknown_routes(app, client):
"""Unknown API routes return 404."""
resp = client.get("/api/unknown")
assert resp.status_code == 404
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_legislator.return_value = None
mock_api.get_legislator.return_value = None
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/legislators/MISSING")
assert resp.status_code == 404
def test_root_redirects(client):
"""Root path redirects to frontend."""
resp = client.get("/")
assert resp.status_code in (301, 302)

View File

@ -0,0 +1,92 @@
"""Integration tests for the Voting App.
Tests that require actual API access and database persistence.
Skipped by default; run with: pytest tests/integration/ -m integration
"""
from __future__ import annotations
import os
import pytest
from src.app import create_app
@pytest.fixture
def app():
a = create_app()
a.config["TESTING"] = True
return a
@pytest.fixture
def client(app):
return app.test_client()
@pytest.mark.integration
def test_full_search_flow(client):
"""Test the complete flow: search -> select legislator -> get votes -> generate summaries."""
congress_key = os.getenv("CONGRESS_API_KEY", "")
if not congress_key:
pytest.skip("CONGRESS_API_KEY not set")
resp = client.get("/api/search?q=mitch")
data = resp.get_json()
assert len(data) >= 1
legislator_id = data[0]["id"]
resp = client.get(f"/api/legislators/{legislator_id}")
assert resp.status_code == 200
resp = client.get(f"/api/legislators/{legislator_id}/votes")
assert resp.status_code == 200
@pytest.mark.integration
def test_cache_persistence(app, client):
"""Test that cached data persists across app restarts."""
congress_key = os.getenv("CONGRESS_API_KEY", "")
if not congress_key:
pytest.skip("CONGRESS_API_KEY not set")
resp = client.get("/api/search?q=mitch")
data = resp.get_json()
assert len(data) >= 1
legislator_id = data[0]["id"]
a2 = create_app()
a2.config["TESTING"] = True
c2 = a2.test_client()
resp = c2.get(f"/api/legislators/{legislator_id}")
assert resp.status_code == 200
@pytest.mark.integration
def test_legislator_votes_integration(client):
"""Fetch real legislator votes from Congress.gov API."""
congress_key = os.getenv("CONGRESS_API_KEY", "")
if not congress_key:
pytest.skip("CONGRESS_API_KEY not set")
resp = client.get("/api/search?q=booker")
data = resp.get_json()
if not data:
pytest.skip("Legislator not found")
# Pick an in-office senator for reliable vote data (Senate XML scanning works independently of API roll calls)
legislator_id = None
for leg in data:
if leg.get("chamber") == "Senate" and leg.get("in_office"):
legislator_id = leg["id"]
break
if legislator_id is None:
pytest.skip("No in-office senator found in search results")
resp = client.get(f"/api/legislators/{legislator_id}/votes?limit=5")
assert resp.status_code == 200
vote_data = resp.get_json()
assert len(vote_data["votes"]) > 0

View File

@ -0,0 +1,369 @@
"""Test Congress.gov v3 API client mapping functions."""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from src.api_client import ApiClient, get_current_congress
@pytest.fixture
def api_client() -> ApiClient:
return ApiClient("https://api.congress.gov", "test-key")
# --- Congress number calculation ---
def test_get_current_congress_2025():
"""Congress 119 started Jan 3, 2025."""
assert get_current_congress() == 1 + (2025 - 1789) // 2
def test_get_current_congress_2026():
"""Congress 120 started Jan 3, 2026."""
assert get_current_congress() == 1 + (2026 - 1789) // 2
# --- Name parsing ---
def test_parse_name_comma_format():
first, last, full = ApiClient._parse_name("McConnell, Mitch")
assert first == "Mitch"
assert last == "McConnell"
assert full == "Mitch McConnell"
def test_parse_name_no_comma():
first, last, full = ApiClient._parse_name("Van De Graaff")
assert first == ""
assert last == "Van De Graaff"
assert full == "Van De Graaff"
# --- Member list mapping ---
def test_map_member_list_basic(api_client: ApiClient):
"""Test mapping a standard member list item."""
item = {
"bioguideId": "M000355",
"name": "McConnell, Mitch",
"partyName": "Republican",
"state": "KY",
"chamber": "Senate",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025", "endYear": "2027"}]},
"depiction": {"imageUrl": "https://example.com/photo.jpg"},
"url": "https://www.congress.gov/member/M000355",
}
leg = api_client._map_member_list(item, 119)
assert leg.id == "M000355"
assert leg.first_name == "Mitch"
assert leg.last_name == "McConnell"
assert leg.full_name == "Mitch McConnell"
assert leg.party == "Republican"
assert leg.state == "KY"
assert leg.chamber == "Senate"
assert leg.in_office is True
assert leg.end_date == date(2027, 1, 3)
assert leg.start_date == date(2025, 1, 3)
assert leg.photo_url == "https://example.com/photo.jpg"
def test_map_member_list_in_office(api_client: ApiClient):
"""No endYear with startYear <= current year means in-office."""
item = {
"bioguideId": "S001234",
"name": "Tester, Jeff",
"partyName": "Democrat",
"state": "ND",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025"}]},
"depiction": {},
}
leg = api_client._map_member_list(item, 119)
assert leg.in_office is True
assert leg.end_date is None
assert leg.start_date == date(2025, 1, 3)
def test_map_member_list_stale_term(api_client: ApiClient):
"""Term with no endYear and startYear <= current year is in-office."""
item = {
"bioguideId": "X000001",
"name": "Old, Senator",
"partyName": "Democrat",
"state": "CA",
"terms": {"item": [{"chamber": "Senate", "startYear": "2023"}]},
"depiction": {},
}
leg = api_client._map_member_list(item, 119)
assert leg.in_office is True
def test_map_member_list_future_term(api_client: ApiClient):
"""Term with startYear in the future is not in-office."""
item = {
"bioguideId": "F000001",
"name": "Future, Member",
"partyName": "Democrat",
"state": "CA",
"terms": {"item": [{"chamber": "Senate", "startYear": str(date.today().year + 5)}]},
"depiction": {},
}
leg = api_client._map_member_list(item, 119)
assert leg.in_office is False
def test_map_member_list_fallback_photo(api_client: ApiClient):
"""Uses congress.gov photo URL when depiction is empty."""
item = {
"bioguideId": "M000355",
"name": "McConnell, Mitch",
"partyName": "Republican",
"state": "KY",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025", "endYear": "2027"}]},
"depiction": {},
}
leg = api_client._map_member_list(item, 119)
assert leg.photo_url == "https://www.congress.gov/img/member/m000355_200.jpg"
# --- Member detail mapping ---
def test_map_member_detail_basic(api_client: ApiClient):
"""Test mapping a member detail response with full term history."""
item = {
"bioguideId": "M000355",
"firstName": "Mitch",
"lastName": "McConnell",
"directOrderName": "Mitch McConnell",
"partyHistory": [{"partyName": "Republican"}],
"state": "KY",
"currentMember": True,
"birthYear": "1942",
"terms": [
{"chamber": "Senate", "startYear": "1985", "endYear": "1987"},
{"chamber": "Senate", "startYear": "1987", "endYear": "2025"},
],
"depiction": {"imageUrl": "https://example.com/photo.jpg"},
"officialWebsiteUrl": "https://www.mcconnell.senate.gov",
}
leg = api_client._map_member_detail(item)
assert leg.id == "M000355"
assert leg.first_name == "Mitch"
assert leg.last_name == "McConnell"
assert leg.full_name == "Mitch McConnell"
assert leg.party == "Republican"
assert leg.state == "KY"
assert leg.chamber == "Senate"
assert leg.in_office is False
assert leg.end_date == date(2025, 1, 3)
assert leg.start_date == date(1985, 1, 3)
assert leg.first_elected == date(1985, 1, 3)
assert leg.birth_date == date(1942, 1, 1)
def test_map_member_detail_house(api_client: ApiClient):
"""Test mapping a House member."""
item = {
"bioguideId": "S000522",
"firstName": "Nancy",
"lastName": "Pelosi",
"directOrderName": "Nancy Pelosi",
"partyHistory": [{"partyName": "Democrat"}],
"state": "CA",
"currentMember": False,
"terms": [
{"chamber": "House", "startYear": "1989", "endYear": "2023"},
],
"depiction": {},
}
leg = api_client._map_member_detail(item)
assert leg.chamber == "House"
assert leg.in_office is False
assert leg.end_date == date(2023, 1, 3)
assert leg.start_date == date(1989, 1, 3)
# --- Bill mapping ---
def test_map_bill(api_client: ApiClient):
"""Test mapping a bill response."""
item = {
"congress": "119",
"type": "HR",
"number": "1",
"title": "Test Bill",
"sponsors": [{"fullName": "John Doe"}],
"latestAction": {"text": "Became Public Law 119-1"},
"policyArea": {"name": "Education"},
"legislationUrl": "https://www.congress.gov/bill/119/HR/1",
}
bill = api_client._map_bill(item)
assert bill.bill_id == "119/HR/1"
assert bill.title == "Test Bill"
assert bill.sponsor == "John Doe"
assert bill.enacted is True
assert bill.subject == "Education"
# --- Bill summary mapping ---
def test_map_bill_summary(api_client: ApiClient):
"""Test mapping a bill summary response."""
item = {"text": "&lt;p&gt;This bill does things.&lt;/p&gt;"}
summary = api_client._map_bill_summary(item)
assert summary.summary_text == "<p>This bill does things.</p>"
assert summary.model_name == "congress.gov"
# --- get_legislator endpoint ---
@patch.object(ApiClient, "_request")
def test_get_legislator(mock_request: MagicMock, api_client: ApiClient):
"""Test fetching a legislator by bioguideId."""
mock_request.return_value = {
"member": {
"bioguideId": "M000355",
"firstName": "Mitch",
"lastName": "McConnell",
"directOrderName": "Mitch McConnell",
"partyHistory": [{"partyName": "Republican"}],
"state": "KY",
"currentMember": True,
"birthYear": "1942",
"terms": [
{"chamber": "Senate", "startYear": "1985", "endYear": "1987"},
{"chamber": "Senate", "startYear": "1987", "endYear": "2025"},
],
"depiction": {"imageUrl": "https://example.com/photo.jpg"},
"officialWebsiteUrl": "https://www.mcconnell.senate.gov",
}
}
leg = api_client.get_legislator("M000355")
assert leg is not None
assert leg.id == "M000355"
assert leg.full_name == "Mitch McConnell"
assert leg.in_office is False
assert leg.end_date == date(2025, 1, 3)
@patch.object(ApiClient, "_request")
def test_get_legislator_not_found(mock_request: MagicMock, api_client: ApiClient):
"""Returns None when API returns no member data."""
mock_request.return_value = {}
assert api_client.get_legislator("MISSING") is None
# --- search_legislators endpoint ---
@patch.object(ApiClient, "_request")
def test_search_legislators(mock_request: MagicMock, api_client: ApiClient):
"""Test searching legislators with paginated Congress.gov v3 response."""
mock_request.return_value = {
"members": [
{
"bioguideId": "M000355",
"name": "McConnell, Mitch",
"partyName": "Republican",
"state": "KY",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025", "endYear": "2027"}]},
"depiction": {},
},
{
"bioguideId": "S001234",
"name": "Tester, Jeff",
"partyName": "Democrat",
"state": "ND",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025"}]},
"depiction": {},
},
],
"pagination": {"next": None},
}
results = api_client.search_legislators("mitch")
assert len(results) == 1
assert results[0].last_name == "McConnell"
results = api_client.search_legislators("tester")
assert len(results) == 1
assert results[0].last_name == "Tester"
@patch.object(ApiClient, "_request")
def test_search_legislators_no_results(mock_request: MagicMock, api_client: ApiClient):
"""Returns empty list when no legislators match."""
mock_request.return_value = {
"members": [
{
"bioguideId": "S001234",
"name": "Tester, Jeff",
"partyName": "Democrat",
"state": "ND",
"terms": {"item": [{"chamber": "Senate", "startYear": "2025"}]},
"depiction": {},
}
],
"pagination": {"next": None},
}
assert api_client.search_legislators("McConnell") == []
@patch.object(ApiClient, "_request")
def test_search_legislators_empty_query(mock_request: MagicMock, api_client: ApiClient):
"""Returns empty list for empty query without calling API."""
assert api_client.search_legislators("") == []
assert not mock_request.called
# --- get_bill endpoint ---
@patch.object(ApiClient, "_request")
def test_get_bill(mock_request: MagicMock, api_client: ApiClient):
"""Test fetching bill with summaries and subjects."""
mock_request.side_effect = [
{
"bill": {
"congress": "119",
"type": "HR",
"number": "1",
"title": "Test Bill",
"sponsors": [{"fullName": "John Doe"}],
"latestAction": {"text": "Referred to committee"},
"policyArea": {"name": "Education"},
"legislationUrl": "",
}
},
{"summaries": [{"text": "&lt;p&gt;Bill summary text.&lt;/p&gt;"}]},
{"subjects": {"legislativeSubjects": [{"name": "Education"}, {"name": "Technology"}]}},
]
bill = api_client.get_bill_text("119/HR/1")
assert bill is not None
assert bill.bill_id == "119/HR/1"
assert bill.title == "Test Bill"
assert bill.text == "<p>Bill summary text.</p>"
assert bill.summary == "<p>Bill summary text.</p>"
assert bill.subject == "Education, Technology"
assert bill.enacted is False
@patch.object(ApiClient, "_request")
def test_get_bill_not_found(mock_request: MagicMock, api_client: ApiClient):
"""Returns None for non-existent bill."""
mock_request.return_value = {}
assert api_client.get_bill_text("999/HR/999") is None
def test_get_bill_invalid_id(api_client: ApiClient):
"""Returns None for malformed bill ID."""
assert api_client.get_bill_text("invalid-id") is None

424
tests/unit/test_app.py Normal file
View File

@ -0,0 +1,424 @@
"""Test Flask application routes."""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from src.app import create_app
from src.models import Legislator, Vote
@pytest.fixture
def app():
a = create_app()
a.config["TESTING"] = True
return a
@pytest.fixture
def client(app):
return app.test_client()
# --- Health endpoint ---
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
# --- Search endpoint ---
def test_search_no_query(client):
resp = client.get("/api/search")
assert resp.get_json() == []
def test_search_empty_query(client):
resp = client.get("/api/search?q=")
assert resp.get_json() == []
def test_search_with_query(app, client):
mock_api = MagicMock()
mock_cache = MagicMock()
mock_cache.search_legislators.return_value = []
mock_api.search_legislators.return_value = [
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
in_office=True,
photo_url="https://example.com/photo.jpg",
)
]
mock_api.save_legislator = MagicMock()
mock_cache.save_legislator = MagicMock()
with patch.dict(app.config, {"API": mock_api, "CACHE": mock_cache}):
resp = client.get("/api/search?q=John")
data = resp.get_json()
assert len(data) == 1
assert data[0]["full_name"] == "John Doe"
assert data[0]["in_office"] is True
assert data[0]["photo_url"] == "https://example.com/photo.jpg"
def test_search_api_failure_returns_empty(app, client):
mock_api = MagicMock()
mock_cache = MagicMock()
mock_cache.search_legislators.return_value = []
mock_api.search_legislators.side_effect = Exception("Network error")
with patch.dict(app.config, {"API": mock_api, "CACHE": mock_cache}):
resp = client.get("/api/search?q=John")
assert resp.get_json() == []
# --- Legislator detail endpoint ---
def test_legislator_detail_cached(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_legislator.return_value = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
end_date=date(2025, 12, 31),
photo_url="https://example.com/photo.jpg",
url="https://www.mcconnell.senate.gov",
)
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/legislators/M000355")
data = resp.get_json()
assert resp.status_code == 200
assert data["full_name"] == "Mitch McConnell"
assert data["in_office"] is False
mock_api.get_legislator.assert_not_called()
def test_legislator_detail_fetches_from_api(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_legislator.return_value = None
mock_api.get_legislator.return_value = Legislator(
id="S001",
first_name="Jane",
last_name="Smith",
full_name="Jane Smith",
party="Democrat",
state="NY",
chamber="House",
in_office=True,
photo_url="https://example.com/jane.jpg",
url="https://www.smith.house.gov",
)
mock_cache.save_legislator = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/legislators/S001")
data = resp.get_json()
assert resp.status_code == 200
assert data["full_name"] == "Jane Smith"
assert data["chamber"] == "House"
mock_cache.save_legislator.assert_called_once()
def test_legislator_not_found(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_legislator.return_value = None
mock_api.get_legislator.return_value = None
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/legislators/MISSING")
assert resp.status_code == 404
assert "not found" in resp.get_json()["error"].lower()
def test_legislator_api_error(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_legislator.return_value = None
mock_api.get_legislator.side_effect = Exception("Network error")
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/legislators/M000355")
assert resp.status_code == 503
# --- Legislator votes endpoint ---
def test_legislator_votes_cached(app, client):
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_leg = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
in_office=True,
end_date=None,
)
mock_cache.get_legislator.return_value = mock_leg
mock_cache.get_voting_record.return_value = (
[
{
"roll_call_id": "RC001",
"vote_type": "Yea",
"vote_date": "2025-06-01",
"bill_id": "119/HR/1",
"bill_title": "Test Bill",
"subject": "Education",
"sponsor": "John Doe",
"enacted": False,
"summary": "Summary text",
"key_measures": ["Measure 1"],
"generated_at": None,
}
],
1,
)
mock_cache.get_bill = MagicMock(return_value=MagicMock())
mock_cache.get_summary = MagicMock(return_value=MagicMock())
with patch.dict(app.config, {"CACHE": mock_cache, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/L001/votes")
data = resp.get_json()
assert resp.status_code == 200
assert len(data["votes"]) == 1
assert data["votes"][0]["vote_type"] == "Yea"
assert data["total"] == 1
mock_vote_client.get_legislator_votes.assert_not_called()
def test_legislator_votes_pagination(app, client):
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_leg = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
in_office=True,
end_date=None,
)
mock_cache.get_legislator.return_value = mock_leg
mock_cache.get_voting_record.side_effect = [
([], 0),
(
[
{
"roll_call_id": "RC001",
"vote_type": "Yea",
"vote_date": "2025-06-01",
"bill_id": "",
"bill_title": "Bill A",
"subject": "",
"sponsor": "",
"enacted": False,
"summary": "",
"key_measures": [],
"generated_at": None,
}
],
1,
),
]
mock_vote_client.get_legislator_votes.return_value = [
Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_title="Bill A"),
]
mock_cache.save_votes = MagicMock()
mock_cache.get_bill = MagicMock(return_value=MagicMock())
mock_cache.get_summary = MagicMock(return_value=MagicMock())
with patch.dict(app.config, {"CACHE": mock_cache, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/L001/votes?limit=5&offset=0")
data = resp.get_json()
assert resp.status_code == 200
assert data["limit"] == 5
assert data["offset"] == 0
def test_legislator_votes_retired_senator_congress(app, client):
"""Retired senator with end_date in Dec should query that congress."""
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_leg = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
)
mock_cache.get_legislator.return_value = mock_leg
mock_cache.get_voting_record.return_value = ([], 0)
mock_vote_client.get_legislator_votes.return_value = []
mock_cache.save_votes = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/M000355/votes")
assert resp.status_code == 200
call_args = mock_vote_client.get_legislator_votes.call_args
congress_arg = call_args.kwargs.get("congress", call_args[0][1] if call_args[0] else None)
# end_date 2025-12-31: congress = 1 + (2025 - 1789) // 2 = 119, no Jan adjustment
assert congress_arg == 119
def test_legislator_votes_retired_senator_jan_end_date(app, client):
"""Retired senator with end_date Jan 3 should query prior congress."""
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_leg = Legislator(
id="X000001",
first_name="Former",
last_name="Senator",
full_name="Former Senator",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(2023, 1, 3),
end_date=date(2025, 1, 3),
)
mock_cache.get_legislator.return_value = mock_leg
mock_cache.get_voting_record.return_value = ([], 0)
mock_vote_client.get_legislator_votes.return_value = []
mock_cache.save_votes = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/X000001/votes")
assert resp.status_code == 200
call_args = mock_vote_client.get_legislator_votes.call_args
congress_arg = call_args.kwargs.get("congress", call_args[0][1] if call_args[0] else None)
# end_date 2025-01-03: congress = 1 + (2025 - 1789) // 2 = 119, Jan 3 adjustment -> 118
assert congress_arg == 118
def test_legislator_votes_congress_param(app, client):
"""Explicit congress param overrides automatic calculation."""
mock_cache = MagicMock()
mock_vote_client = MagicMock()
mock_leg = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
mock_cache.get_legislator.return_value = mock_leg
mock_cache.get_voting_record.return_value = ([], 0)
mock_vote_client.get_legislator_votes.return_value = []
mock_cache.save_votes = MagicMock()
with patch.dict(app.config, {"CACHE": mock_cache, "VOTE_CLIENT": mock_vote_client}):
resp = client.get("/api/legislators/L001/votes?congress=117")
assert resp.status_code == 200
call_args = mock_vote_client.get_legislator_votes.call_args
congress_arg = call_args.kwargs.get("congress", call_args[0][1] if call_args[0] else None)
assert congress_arg == 117
# --- Bill summary endpoint ---
def test_bill_summary_cached(app, client):
from src.models import Summary
mock_cache = MagicMock()
mock_cache.get_summary.return_value = Summary(
bill_id="119/HR/1",
summary_text="This bill does X.",
key_measures=["Measure A"],
model_name="gpt-oss",
)
with patch.dict(app.config, {"CACHE": mock_cache}):
resp = client.get("/api/bills/119/HR/1/summary")
data = resp.get_json()
assert resp.status_code == 200
assert data["summary_text"] == "This bill does X."
assert data["key_measures"] == ["Measure A"]
def test_bill_summary_not_found(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_summary.return_value = None
mock_cache.get_bill.return_value = None
mock_api.get_bill_text.return_value = None
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/bills/999/HR/999/summary")
assert resp.status_code == 404
# --- Bill text endpoint ---
def test_bill_text_cached(app, client):
from src.models import Bill
mock_cache = MagicMock()
mock_cache.get_bill.return_value = Bill(
bill_id="119/HR/1",
title="Test Bill",
subject="Education",
text="Full text here",
sponsor="John Doe",
enacted=True,
)
with patch.dict(app.config, {"CACHE": mock_cache}):
resp = client.get("/api/bills/119/HR/1/text")
data = resp.get_json()
assert resp.status_code == 200
assert data["title"] == "Test Bill"
assert data["enacted"] is True
def test_bill_text_not_found(app, client):
mock_cache = MagicMock()
mock_api = MagicMock()
mock_cache.get_bill.return_value = None
mock_api.get_bill_text.return_value = None
with patch.dict(app.config, {"CACHE": mock_cache, "API": mock_api}):
resp = client.get("/api/bills/999/HR/999/text")
assert resp.status_code == 404
# --- Catch-all ---
def test_catch_all_api(client):
resp = client.get("/api/nonexistent")
assert resp.status_code == 404
def test_root_redirect(client):
resp = client.get("/")
assert resp.status_code in (302, 301)

297
tests/unit/test_cache.py Normal file
View File

@ -0,0 +1,297 @@
"""Test SQLite cache operations."""
from __future__ import annotations
import os
import tempfile
from datetime import date
import pytest
from src.cache import Cache
from src.models import Bill, Legislator, Summary, Vote
@pytest.fixture
def cache() -> Cache:
fd, path = tempfile.mkstemp(suffix=".db")
c = Cache(path)
yield c
c.close()
os.close(fd)
os.unlink(path)
# --- Legislators ---
def test_save_and_get_legislator(cache: Cache):
leg = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
cache.save_legislator(leg)
fetched = cache.get_legislator("L001")
assert fetched is not None
assert fetched.id == "L001"
assert fetched.full_name == "John Doe"
assert fetched.party == "Democrat"
assert fetched.start_date == date(2025, 1, 3)
assert fetched.end_date is None
def test_get_missing_legislator(cache: Cache):
assert cache.get_legislator("MISSING") is None
def test_search_legislators(cache: Cache):
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
cache.save_legislator(
Legislator(
id="L002",
first_name="Jane",
last_name="Smith",
full_name="Jane Smith",
party="Republican",
state="NY",
chamber="House",
)
)
results = cache.search_legislators("John")
assert len(results) == 1
assert results[0].full_name == "John Doe"
results = cache.search_legislators("Smith")
assert len(results) == 1
assert results[0].last_name == "Smith"
def test_legislator_retired(cache: Cache):
leg = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
)
cache.save_legislator(leg)
fetched = cache.get_legislator("M000355")
assert fetched is not None
assert fetched.in_office is False
assert fetched.start_date == date(1985, 1, 3)
assert fetched.end_date == date(2025, 12, 31)
def test_legislator_update(cache: Cache):
"""Updating a legislator preserves all fields."""
leg = Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
start_date=date(2025, 1, 3),
end_date=None,
)
cache.save_legislator(leg)
leg.party = "Independent"
leg.in_office = False
leg.end_date = date(2027, 12, 31)
cache.save_legislator(leg)
fetched = cache.get_legislator("L001")
assert fetched.party == "Independent"
assert fetched.in_office is False
assert fetched.end_date == date(2027, 12, 31)
assert fetched.start_date == date(2025, 1, 3)
# --- Bills ---
def test_save_and_get_bill(cache: Cache):
bill = Bill(bill_id="B100", title="Test Bill", subject="Education", text="Full text", enacted=True)
cache.save_bill(bill)
fetched = cache.get_bill("B100")
assert fetched is not None
assert fetched.title == "Test Bill"
assert fetched.subject == "Education"
assert fetched.enacted is True
def test_get_missing_bill(cache: Cache):
assert cache.get_bill("MISSING") is None
# --- Votes ---
def test_save_and_get_vote(cache: Cache):
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
vote = Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_id="B100", bill_title="Test Bill")
cache.save_vote(vote)
votes = cache.get_votes("L001")
assert len(votes) == 1
assert votes[0].vote_type == "Yea"
def test_save_multiple_votes(cache: Cache):
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
cache.save_votes(
[
Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_id="B100", bill_title="Bill A"),
Vote(legislator_id="L001", roll_call_id="RC002", vote_type="Nay", bill_id="B101", bill_title="Bill B"),
]
)
votes = cache.get_votes("L001")
assert len(votes) == 2
def test_vote_duplicate_ignored(cache: Cache):
"""INSERT OR IGNORE prevents duplicate votes."""
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
vote = Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_id="B100", bill_title="Test Bill")
cache.save_vote(vote)
cache.save_vote(vote)
votes = cache.get_votes("L001")
assert len(votes) == 1
# --- Summaries ---
def test_save_and_get_summary(cache: Cache):
cache.save_bill(Bill(bill_id="B100", title="Test Bill", subject="Education"))
summary = Summary(
bill_id="B100",
summary_text="This bill does X.",
key_measures=["Measure A", "Measure B"],
model_name="gpt-oss",
)
cache.save_summary(summary)
fetched = cache.get_summary("B100")
assert fetched is not None
assert fetched.summary_text == "This bill does X."
assert fetched.key_measures == ["Measure A", "Measure B"]
assert fetched.model_name == "gpt-oss"
def test_get_missing_summary(cache: Cache):
assert cache.get_summary("MISSING") is None
# --- Voting Record ---
def test_voting_record(cache: Cache):
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
cache.save_bill(Bill(bill_id="B100", title="Test Bill", subject="Education"))
cache.save_vote(
Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_id="B100", bill_title="Test Bill")
)
records, total = cache.get_voting_record("L001")
assert len(records) == 1
assert total == 1
assert records[0]["vote_type"] == "Yea"
assert records[0]["bill_title"] == "Test Bill"
def test_voting_record_empty(cache: Cache):
records, total = cache.get_voting_record("MISSING")
assert records == []
assert total == 0
def test_voting_record_pagination(cache: Cache):
cache.save_legislator(
Legislator(
id="L001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
)
)
cache.save_votes(
[
Vote(legislator_id="L001", roll_call_id=f"RC{i}", vote_type="Yea", bill_id=f"B{i}", bill_title=f"Bill {i}")
for i in range(10)
]
)
records, total = cache.get_voting_record("L001", limit=3, offset=0)
assert len(records) == 3
assert total == 10
records, total = cache.get_voting_record("L001", limit=3, offset=3)
assert len(records) == 3
records, total = cache.get_voting_record("L001", limit=3, offset=9)
assert len(records) == 1

View File

@ -0,0 +1,129 @@
"""Test GPT-OSS client with mocked responses."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from src.gpt_oss_client import GptOssClient
from src.models import Bill
@pytest.fixture
def gpt_client() -> GptOssClient:
return GptOssClient("http://localhost:4000/v1", "test-gpt-key")
@patch.object(GptOssClient, "_request")
def test_generate_summary(mock_request: MagicMock, gpt_client: GptOssClient):
mock_request.return_value = {
"choices": [
{
"message": {
"content": (
"SUMMARY: This bill establishes new regulations for data privacy.\n"
"KEY MEASURES:\n"
"- Requires companies to obtain user consent\n"
"- Mandates data breach notifications\n"
)
}
}
]
}
bill = Bill(bill_id="B100", title="Data Privacy Act", text="Full bill text about data privacy...")
summary = gpt_client.generate_summary(bill)
assert summary.bill_id == "B100"
assert "data privacy" in summary.summary_text.lower()
assert len(summary.key_measures) == 2
assert "Requires companies to obtain user consent" in summary.key_measures
assert "Mandates data breach notifications" in summary.key_measures
assert summary.model_name == "gpt-oss"
@patch.object(GptOssClient, "_request")
def test_generate_summary_no_text(mock_request: MagicMock, gpt_client: GptOssClient):
"""When text is empty and title exists, fallback text is used to call API."""
mock_request.return_value = {}
bill = Bill(bill_id="B100", title="Empty Bill", text="")
summary = gpt_client.generate_summary(bill)
assert summary.summary_text == "Summary generation failed."
assert summary.key_measures == ["Summary generation failed."]
mock_request.assert_called_once()
@patch.object(GptOssClient, "_request")
def test_generate_summary_no_text_uses_title(mock_request: MagicMock, gpt_client: GptOssClient):
"""When text is empty but title/subject exist, uses those for summary prompt."""
mock_request.return_value = {
"choices": [{"message": {"content": "SUMMARY: Some summary.\nKEY MEASURES:\n- Measure 1\n"}}]
}
bill = Bill(bill_id="B100", title="Data Privacy Act", subject="Privacy", text="")
summary = gpt_client.generate_summary(bill)
assert "Some summary" in summary.summary_text
assert summary.key_measures == ["Measure 1"]
mock_request.assert_called_once()
call_args = mock_request.call_args
messages = call_args[0][0]
assert "Data Privacy Act: Privacy" in messages[1]["content"]
@patch.object(GptOssClient, "_request")
def test_generate_summary_failure(mock_request: MagicMock, gpt_client: GptOssClient):
mock_request.return_value = {}
bill = Bill(bill_id="B100", title="Test Bill", text="Some text")
summary = gpt_client.generate_summary(bill)
assert summary.summary_text == "Summary generation failed."
assert summary.key_measures == ["Summary generation failed."]
@patch.object(GptOssClient, "_request")
def test_generate_summary_truncates_long_text(mock_request: MagicMock, gpt_client: GptOssClient):
"""Text longer than 6000 chars is truncated before sending."""
mock_request.return_value = {"choices": [{"message": {"content": "SUMMARY: Short.\nKEY MEASURES:\n- M1\n"}}]}
bill = Bill(bill_id="B100", title="Long Bill", text="x" * 10000)
gpt_client.generate_summary(bill)
call_args = mock_request.call_args
messages = call_args[0][0]
user_content = messages[1]["content"]
assert len(user_content) < 6100
def test_extract_key_measures():
text = (
"SUMMARY: This bill does important things.\n"
"KEY MEASURES:\n"
"- First measure is important\n"
"- Second measure matters too\n"
"Additional context here."
)
measures = GptOssClient._extract_key_measures(text)
assert len(measures) == 2
assert "First measure is important" in measures
assert "Second measure matters too" in measures
def test_extract_key_measures_empty():
"""Returns summary text fallback when no measures found."""
text = "This is just a regular paragraph with no key measures section."
measures = GptOssClient._extract_key_measures(text)
assert len(measures) == 1
assert measures[0] == "This is just a regular paragraph with no key measures section."
def test_extract_key_measures_partial():
"""Handles measures section followed by non-bullet text."""
text = "SUMMARY: Overview.\nKEY MEASURES:\n- First measure\nSome other text\n- This should not be included\n"
measures = GptOssClient._extract_key_measures(text)
assert len(measures) == 1
assert measures[0] == "First measure"
def test_extract_key_measures_fallback_truncated():
"""Fallback summary is truncated to 200 chars."""
text = "x" * 300
measures = GptOssClient._extract_key_measures(text)
assert len(measures) == 1
assert len(measures[0]) == 200

119
tests/unit/test_models.py Normal file
View File

@ -0,0 +1,119 @@
"""Test data models."""
from datetime import date
from src.models import Bill, Legislator, Summary, Vote
def test_legislator_creation():
leg = Legislator(
id="L000001",
first_name="John",
last_name="Doe",
full_name="John Doe",
party="Democrat",
state="CA",
chamber="Senate",
birth_date=date(1960, 1, 15),
photo_url="https://example.com/photo.jpg",
url="https://example.com",
in_office=True,
start_date=date(2025, 1, 3),
end_date=None,
)
assert leg.id == "L000001"
assert leg.full_name == "John Doe"
assert leg.party == "Democrat"
assert leg.in_office is True
assert leg.start_date == date(2025, 1, 3)
assert leg.end_date is None
def test_legislator_defaults():
leg = Legislator(
id="L002", first_name="Jane", last_name="Smith", full_name="Jane Smith", party="", state="", chamber=""
)
assert leg.in_office is True
assert leg.birth_date is None
assert leg.photo_url is None
assert leg.url is None
assert leg.start_date is None
assert leg.end_date is None
def test_legislator_retired():
leg = Legislator(
id="M000355",
first_name="Mitch",
last_name="McConnell",
full_name="Mitch McConnell",
party="Republican",
state="KY",
chamber="Senate",
in_office=False,
start_date=date(1985, 1, 3),
end_date=date(2025, 12, 31),
)
assert leg.in_office is False
assert leg.start_date == date(1985, 1, 3)
assert leg.end_date == date(2025, 12, 31)
def test_bill_creation():
bill = Bill(bill_id="B100", title="Test Bill", subject="Education", text="Full bill text here", enacted=True)
assert bill.bill_id == "B100"
assert bill.enacted is True
assert bill.summary == ""
assert bill.key_measures == []
assert bill.api_response == ""
def test_bill_defaults():
bill = Bill(bill_id="119/hr/1", title="Test Bill")
assert bill.subject == ""
assert bill.text == ""
assert bill.summary == ""
assert bill.sponsor == ""
assert bill.committee == ""
assert bill.enacted is False
assert bill.enacted_date is None
def test_vote_creation():
vote = Vote(legislator_id="L001", roll_call_id="RC001", vote_type="Yea", bill_id="B100", bill_title="Test Bill")
assert vote.vote_type == "Yea"
assert vote.vote_date is None
def test_vote_with_date():
vote = Vote(
legislator_id="L001",
roll_call_id="RC001",
vote_type="Nay",
bill_id="119/hr/1",
vote_date=date(2025, 6, 15),
bill_title="Privacy Act",
)
assert vote.vote_type == "Nay"
assert vote.vote_date == date(2025, 6, 15)
assert vote.bill_title == "Privacy Act"
def test_summary_creation():
summary = Summary(bill_id="B100", summary_text="This bill does X, Y, Z.", key_measures=["Measure A", "Measure B"])
assert len(summary.key_measures) == 2
assert summary.model_name == ""
assert summary.generated_at is None
def test_summary_full():
summary = Summary(
bill_id="119/hr/1",
summary_text="Comprehensive summary.",
key_measures=["Provision 1", "Provision 2", "Provision 3"],
generated_at=None,
model_name="gpt-oss",
)
assert summary.bill_id == "119/hr/1"
assert len(summary.key_measures) == 3
assert summary.model_name == "gpt-oss"

View File

@ -0,0 +1,165 @@
"""Test VoteClient helper functions."""
from __future__ import annotations
from datetime import date
import pytest
from src.models import Vote
from src.vote_client import VoteClient, get_current_congress, get_session
@pytest.fixture
def vote_client() -> VoteClient:
return VoteClient("https://api.congress.gov", "test-key")
# --- Congress/session calculation ---
def test_get_session_odd_year():
assert get_session(2025) == 1
assert get_session(2023) == 1
assert get_session(1789) == 1
def test_get_session_even_year():
assert get_session(2026) == 2
assert get_session(2024) == 2
assert get_session(1790) == 2
def test_get_current_congress_2025():
assert get_current_congress() == 1 + (2025 - 1789) // 2
def test_get_current_congress_2026():
assert get_current_congress() == 1 + (2026 - 1789) // 2
# --- Vote normalization ---
def test_normalize_vote_yea():
assert VoteClient._normalize_vote_cast("Yea") == "Yea"
assert VoteClient._normalize_vote_cast("Aye") == "Yea"
assert VoteClient._normalize_vote_cast("aye") == "Yea"
assert VoteClient._normalize_vote_cast("Y") == "Yea"
assert VoteClient._normalize_vote_cast("y") == "Yea"
assert VoteClient._normalize_vote_cast("Yes") == "Yea"
assert VoteClient._normalize_vote_cast("yes") == "Yea"
def test_normalize_vote_nay():
assert VoteClient._normalize_vote_cast("Nay") == "Nay"
assert VoteClient._normalize_vote_cast("No") == "Nay"
assert VoteClient._normalize_vote_cast("no") == "Nay"
assert VoteClient._normalize_vote_cast("N") == "Nay"
assert VoteClient._normalize_vote_cast("n") == "Nay"
def test_normalize_vote_present():
assert VoteClient._normalize_vote_cast("Present") == "Present"
assert VoteClient._normalize_vote_cast("present") == "Present"
assert VoteClient._normalize_vote_cast("P") == "Present"
assert VoteClient._normalize_vote_cast("p") == "Present"
assert VoteClient._normalize_vote_cast("Answered Present") == "Present"
def test_normalize_vote_not_voting():
assert VoteClient._normalize_vote_cast("Not Voting") == "Not Voting"
assert VoteClient._normalize_vote_cast("not voting") == "Not Voting"
assert VoteClient._normalize_vote_cast("Absent") == "Not Voting"
assert VoteClient._normalize_vote_cast("absent") == "Not Voting"
def test_normalize_vote_unknown():
assert VoteClient._normalize_vote_cast("Unknown") == "Unknown"
assert VoteClient._normalize_vote_cast(" Yea ") == "Yea"
# --- Date parsing ---
def test_parse_date_iso():
assert VoteClient._parse_date("2025-06-15") == date(2025, 6, 15)
assert VoteClient._parse_date("2025-06-15T10:30:00") == date(2025, 6, 15)
def test_parse_date_empty():
assert VoteClient._parse_date("") is None
assert VoteClient._parse_date("invalid") is None
def test_parse_senate_date():
assert VoteClient._parse_senate_date("July 1, 2025, 11:56 AM") == date(2025, 7, 1)
assert VoteClient._parse_senate_date("January 3, 2025") == date(2025, 1, 3)
assert VoteClient._parse_senate_date("December 31, 2024, 12:00 PM") == date(2024, 12, 31)
def test_parse_senate_date_empty():
assert VoteClient._parse_senate_date("") is None
assert VoteClient._parse_senate_date("not a date") is None
# --- State normalization ---
def test_normalize_state_full():
assert VoteClient._normalize_state("California") == "CA"
assert VoteClient._normalize_state("Kentucky") == "KY"
assert VoteClient._normalize_state("New York") == "NY"
def test_normalize_state_abbr():
assert VoteClient._normalize_state("CA") == "CA"
assert VoteClient._normalize_state("KY") == "KY"
assert VoteClient._normalize_state("NY") == "NY"
def test_normalize_state_unknown():
assert VoteClient._normalize_state("Unknown") == "UNKNOWN"
# --- Bill ID resolution ---
def test_resolve_bill_id_hr():
roll_call = {"legislationType": "HR", "legislationNumber": "1", "congress": "119"}
assert VoteClient("https://api.congress.gov", "")._resolve_bill_id(roll_call) == "119/HR/1"
def test_resolve_bill_id_hjres():
roll_call = {"legislationType": "HJRES", "legislationNumber": "50", "congress": "119"}
assert VoteClient("https://api.congress.gov", "")._resolve_bill_id(roll_call) == "119/HJRES/50"
def test_resolve_bill_id_hres():
roll_call = {"legislationType": "HRES", "legislationNumber": "10", "congress": "119"}
assert VoteClient("https://api.congress.gov", "")._resolve_bill_id(roll_call) == "119/HRES/10"
def test_resolve_bill_id_missing_fields():
assert VoteClient("https://api.congress.gov", "")._resolve_bill_id({}) == ""
assert VoteClient("https://api.congress.gov", "")._resolve_bill_id({"legislationType": "HR"}) == ""
# --- Vote object creation ---
def test_vote_creation():
vote = Vote(
legislator_id="M000355",
roll_call_id="123",
vote_type="Yea",
bill_id="119/hr/1",
vote_date=date(2025, 6, 15),
bill_title="HR 1",
)
assert vote.legislator_id == "M000355"
assert vote.roll_call_id == "123"
assert vote.vote_type == "Yea"
assert vote.bill_id == "119/hr/1"
assert vote.vote_date == date(2025, 6, 15)
assert vote.bill_title == "HR 1"