From a9448e2969ae45878fd5f03113f04f26b52c3f5b Mon Sep 17 00:00:00 2001 From: jarianc Date: Fri, 15 May 2026 15:16:18 -0500 Subject: [PATCH] voting app tracker --- .dockerignore | 14 + .env.example | 20 + .gitignore | 42 ++ .hadolint.yaml | 6 + AGENTS.md | 97 +++ docker/.dockerignore.backend | 20 + docker/Dockerfile.backend | 24 + docker/Dockerfile.frontend | 33 + docker/docker-compose.yml | 39 + frontend/.dockerignore | 10 + frontend/.eslintrc.cjs | 23 + frontend/.prettierrc | 8 + frontend/index.html | 13 + frontend/package.json | 33 + frontend/pnpm-workspace.yaml | 2 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 17 + frontend/src/components/common/VoteCard.tsx | 86 +++ frontend/src/components/forms/SearchForm.tsx | 119 +++ frontend/src/components/layout/Layout.tsx | 17 + frontend/src/components/ui/Button.tsx | 46 ++ frontend/src/components/ui/Input.tsx | 32 + frontend/src/components/ui/Spinner.tsx | 14 + frontend/src/components/ui/index.ts | 3 + frontend/src/hooks/useApi.ts | 36 + frontend/src/main.tsx | 10 + frontend/src/pages/Home.tsx | 57 ++ frontend/src/pages/LegislatorDetail.tsx | 168 +++++ frontend/src/services/api.ts | 44 ++ frontend/src/store/appStore.ts | 129 ++++ frontend/src/styles/globals.css | 48 ++ frontend/src/types/api.ts | 53 ++ frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 23 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 10 + prompt-v0.1.md | 56 ++ pyproject.toml | 37 + src/__init__.py | 1 + src/api_client.py | 460 +++++++++++ src/app.py | 328 ++++++++ src/cache.py | 327 ++++++++ src/gpt_oss_client.py | 138 ++++ src/main.py | 35 + src/models.py | 66 ++ src/vote_client.py | 754 +++++++++++++++++++ tests/e2e/test_e2e.py | 365 +++++++++ tests/integration/test_api.py | 92 +++ tests/unit/test_api_client.py | 369 +++++++++ tests/unit/test_app.py | 424 +++++++++++ tests/unit/test_cache.py | 297 ++++++++ tests/unit/test_gpt_oss_client.py | 129 ++++ tests/unit/test_models.py | 119 +++ tests/unit/test_vote_client.py | 165 ++++ 55 files changed, 5498 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .hadolint.yaml create mode 100644 AGENTS.md create mode 100644 docker/.dockerignore.backend create mode 100644 docker/Dockerfile.backend create mode 100644 docker/Dockerfile.frontend create mode 100644 docker/docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.prettierrc create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/common/VoteCard.tsx create mode 100644 frontend/src/components/forms/SearchForm.tsx create mode 100644 frontend/src/components/layout/Layout.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/Spinner.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/hooks/useApi.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/pages/LegislatorDetail.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/store/appStore.ts create mode 100644 frontend/src/styles/globals.css create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 prompt-v0.1.md create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/api_client.py create mode 100644 src/app.py create mode 100644 src/cache.py create mode 100644 src/gpt_oss_client.py create mode 100644 src/main.py create mode 100644 src/models.py create mode 100644 src/vote_client.py create mode 100644 tests/e2e/test_e2e.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/unit/test_api_client.py create mode 100644 tests/unit/test_app.py create mode 100644 tests/unit/test_cache.py create mode 100644 tests/unit/test_gpt_oss_client.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_vote_client.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..06ce517 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.env +.git +node_modules +__pycache__ +*.pyc +dist +build +.venv +data +logs +*.log +tests +tools +additionaldocs \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1781be1 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18eb68a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..b1065bf --- /dev/null +++ b/.hadolint.yaml @@ -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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5b5f2d1 --- /dev/null +++ b/AGENTS.md @@ -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). \ No newline at end of file diff --git a/docker/.dockerignore.backend b/docker/.dockerignore.backend new file mode 100644 index 0000000..b2526ea --- /dev/null +++ b/docker/.dockerignore.backend @@ -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 \ No newline at end of file diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..27fe9cc --- /dev/null +++ b/docker/Dockerfile.backend @@ -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 \ No newline at end of file diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000..6e78d0f --- /dev/null +++ b/docker/Dockerfile.frontend @@ -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 \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..fb8775a --- /dev/null +++ b/docker/docker-compose.yml @@ -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"] \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..ad0f795 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.git +.gitignore +.vscode +.idea +*.swp +.DS_Store +src +.. \ No newline at end of file diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0472180 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -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', + }, + }, +}; \ No newline at end of file diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..0ee22dc --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d6832f9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Voting App + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fabb2ce --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..fbe14a4 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5a3959e --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + }> + } /> + } /> + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/common/VoteCard.tsx b/frontend/src/components/common/VoteCard.tsx new file mode 100644 index 0000000..879a994 --- /dev/null +++ b/frontend/src/components/common/VoteCard.tsx @@ -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 ( +
+
+
+
+ + {vote.vote_type} + + {vote.bill_title} +
+ {vote.vote_date && ( + {vote.vote_date} + )} + {vote.subject && ( + {vote.subject} + )} +
+ + + +
+ + {isExpanded && ( +
+ {vote.summary ? ( + <> +

{vote.summary}

+ {vote.key_measures.length > 0 && ( +
+

+ Key Measures +

+
    + {vote.key_measures.map((measure, idx) => ( +
  • + + {measure} +
  • + ))} +
+
+ )} + + ) : vote.bill_id ? ( +
+ + Summary pending — will populate shortly +
+ ) : ( +

Procedural vote — no summary available

+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/forms/SearchForm.tsx b/frontend/src/components/forms/SearchForm.tsx new file mode 100644 index 0000000..982368e --- /dev/null +++ b/frontend/src/components/forms/SearchForm.tsx @@ -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>(); + const containerRef = useRef(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 ( +
+ { + setSearchQuery(q); + setActiveIndex(0); + }} + placeholder="Search for a legislator..." + onKeyDown={handleKeyDown} + /> + + {showSuggestions && searchResults.length > 0 && ( +
+ {searchResults.map((leg, idx) => ( +
handleSelect(leg)} + > +
+ {leg.full_name} + {leg.photo_url && ( + + )} +
{leg.state} {leg.chamber}
+
+ + {leg.party} + +
+ ))} +
+ )} + + {isSearching && ( +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..d663c33 --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from 'react-router-dom'; + +export function Layout() { + return ( +
+
+
+

Voting App

+

U.S. Congress Voting History

+
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..5c4322b --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -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( + ({ 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.displayName = 'Button'; \ No newline at end of file diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..4a59bff --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,32 @@ +import { forwardRef, useRef } from 'react'; + +interface InputProps { + value: string; + onChange: (value: string) => void; + onKeyDown?: React.KeyboardEventHandler; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const Input = forwardRef( + ({ value, onChange, onKeyDown, placeholder, disabled, className = '' }, ref) => { + const innerRef = useRef(null); + const combinedRef = ref || innerRef; + + return ( + onChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + disabled={disabled} + /> + ); + }, +); + +Input.displayName = 'Input'; \ No newline at end of file diff --git a/frontend/src/components/ui/Spinner.tsx b/frontend/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..eb2fd11 --- /dev/null +++ b/frontend/src/components/ui/Spinner.tsx @@ -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 ( + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..e43c650 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { Button } from './Button'; +export { Input } from './Input'; +export { Spinner } from './Spinner'; \ No newline at end of file diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts new file mode 100644 index 0000000..38bfae6 --- /dev/null +++ b/frontend/src/hooks/useApi.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { request } from '../services/api'; + +export function useApi(endpoint: string, deps: any[] = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + const fetcher = async () => { + try { + const result = await request(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 }; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5ec0def --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +); \ No newline at end of file diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..e98cca3 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -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 ( +
+
+

Track Legislative Voting Records

+

+ Search for any U.S. Congress legislator to view their complete voting history + with AI-generated bill summaries. +

+
+ +
+
+ + +
+
+ +
+
+
Search
+

Find any legislator

+
+
+
Explore
+

View voting history

+
+
+
Understand
+

AI bill summaries

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/LegislatorDetail.tsx b/frontend/src/pages/LegislatorDetail.tsx new file mode 100644 index 0000000..98ebc71 --- /dev/null +++ b/frontend/src/pages/LegislatorDetail.tsx @@ -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(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 ( +
+ +

Loading legislator information...

+
+ ); + } + + if (!selectedLegislator) { + return ( +
+

Legislator not found.

+ +
+ ); + } + + return ( +
+
+ +
+ +
+
+
+

{selectedLegislator.full_name}

+
+ + {selectedLegislator.party} + + + {selectedLegislator.state} + + + {selectedLegislator.chamber} + + + {selectedLegislator.in_office ? 'In Office' : 'Former'} + +
+
+ +
+
+ {voteTotal} total votes recorded +
+
+ +
+ {votes.map((vote) => ( + + ))} +
+ + {isLoadingVotes && ( +
+ + Loading more votes... +
+ )} + + {!voteHasMore && votes.length > 0 && ( +
+ End of voting history +
+ )} + + {votes.length === 0 && !isLoadingVotes && !isLoadingLegislator && ( +
+ No voting records found +
+ )} + + {voteHasMore && ( +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..482cd03 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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(path: string, params?: Record): Promise { + 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 => + request('/api/search', { q: query }), + + getLegislator: (id: string): Promise => + request(`/api/legislators/${id}`), + + getLegislatorVotes: ( + legislatorId: string, + limit: number, + offset: number, + ): Promise => + request(`/api/legislators/${legislatorId}/votes`, { + limit: String(limit), + offset: String(offset), + }), + + getBillSummary: (billId: string): Promise => + request(`/api/bills/${billId}/summary`), + + getBillText: (billId: string): Promise => + request(`/api/bills/${billId}/text`), + + healthCheck: (): Promise<{ status: string }> => + request<{ status: string }>('/health'), +}; \ No newline at end of file diff --git a/frontend/src/store/appStore.ts b/frontend/src/store/appStore.ts new file mode 100644 index 0000000..2d0f94f --- /dev/null +++ b/frontend/src/store/appStore.ts @@ -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; + + // Selected legislator + selectedLegislator: Legislator | null; + isLoadingLegislator: boolean; + selectLegislator: (id: string) => Promise; + setSelectedLegislator: (legislator: Legislator | null) => void; + + // Voting record + votes: any[]; + voteOffset: number; + voteLimit: number; + voteTotal: number; + voteHasMore: boolean; + isLoadingVotes: boolean; + loadVotes: (limit?: number) => Promise; + resetVotes: () => void; + refreshVotes: () => Promise; + + // Bill detail + expandedBillId: string | null; + setExpandedBillId: (billId: string | null) => void; +} + +export const useAppStore = create((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 }), +})); \ No newline at end of file diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..16c477e --- /dev/null +++ b/frontend/src/styles/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..6bb422d --- /dev/null +++ b/frontend/src/types/api.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..8a44ad5 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +}; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d0104ed --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..e74018c --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "composite": true, + "allowImportingTsExtensions": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5f5f557 --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, +}); \ No newline at end of file diff --git a/prompt-v0.1.md b/prompt-v0.1.md new file mode 100644 index 0000000..58edb89 --- /dev/null +++ b/prompt-v0.1.md @@ -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 locally‑hosted GPT‑OSS endpoint (`http://home.ms:4000/v1`) and stores the model’s output. +> 5. Caches all external data locally (SQLite or a simple key‑value store) to avoid repeated API calls and to survive API outages. +> 6. Handles pagination, rate‑limit back‑off, 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 GPT‑OSS‑derived explanation for each bill. +> +> 8. Uses type‑hints, dataclasses, and a modular architecture so each component can be unit‑tested. +> 9. Includes a README explaining how to set up the API key, run the local GPT‑OSS server, and execute the program. + +> **Desired Output**: +> +> - An overall architecture diagram (text‑based or Markdown flow chart). +> - A folder structure (e.g., `src/`, `tests/`, `cache/`). +> - Full Python code for: +> +> - `api_client.py` (handles Congress API, pagination, back‑off). +> - `gpt_oss_client.py` (handles local GPT‑OSS calls). +> - `cache.py` (simple SQLite wrapper). +> - `main.py` (CLI entry point). +> - `models.py` (dataclasses for Legislator, Bill, Vote). +> - `README.md` with setup instructions. +> +> - Sample unit‑test 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`. +> - GPT‑OSS calls must use the same bearer‑token 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 GPT‑OSS 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 what’s 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 you’re 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aef9e3e --- /dev/null +++ b/pyproject.toml @@ -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)", +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..3b3e7c3 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Voting App - U.S. Congress legislator voting history viewer.""" diff --git a/src/api_client.py b/src/api_client.py new file mode 100644 index 0000000..bfef5b1 --- /dev/null +++ b/src/api_client.py @@ -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", + ) diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..5d60acd --- /dev/null +++ b/src/app.py @@ -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/") + 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//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//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//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, + } diff --git a/src/cache.py b/src/cache.py new file mode 100644 index 0000000..5e555ae --- /dev/null +++ b/src/cache.py @@ -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 diff --git a/src/gpt_oss_client.py b/src/gpt_oss_client.py new file mode 100644 index 0000000..3a2eff6 --- /dev/null +++ b/src/gpt_oss_client.py @@ -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: \n" + "KEY MEASURES:\n" + "- \n" + "- \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]] diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a73f1f3 --- /dev/null +++ b/src/main.py @@ -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() diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..d7323ca --- /dev/null +++ b/src/models.py @@ -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 = "" diff --git a/src/vote_client.py b/src/vote_client.py new file mode 100644 index 0000000..da3e74e --- /dev/null +++ b/src/vote_client.py @@ -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 diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py new file mode 100644 index 0000000..062d875 --- /dev/null +++ b/tests/e2e/test_e2e.py @@ -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) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..042d34c --- /dev/null +++ b/tests/integration/test_api.py @@ -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 diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py new file mode 100644 index 0000000..453abd3 --- /dev/null +++ b/tests/unit/test_api_client.py @@ -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": "<p>This bill does things.</p>"} + summary = api_client._map_bill_summary(item) + assert summary.summary_text == "

This bill does things.

" + 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": "<p>Bill summary text.</p>"}]}, + {"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 == "

Bill summary text.

" + assert bill.summary == "

Bill summary text.

" + 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 diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 0000000..0b7aa05 --- /dev/null +++ b/tests/unit/test_app.py @@ -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) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py new file mode 100644 index 0000000..731c370 --- /dev/null +++ b/tests/unit/test_cache.py @@ -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 diff --git a/tests/unit/test_gpt_oss_client.py b/tests/unit/test_gpt_oss_client.py new file mode 100644 index 0000000..a72b813 --- /dev/null +++ b/tests/unit/test_gpt_oss_client.py @@ -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 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..327e11f --- /dev/null +++ b/tests/unit/test_models.py @@ -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" diff --git a/tests/unit/test_vote_client.py b/tests/unit/test_vote_client.py new file mode 100644 index 0000000..2efdc5e --- /dev/null +++ b/tests/unit/test_vote_client.py @@ -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"