Spaces:
No application file
No application file
| """ | |
| PoliticoAI – Hugging Face Space edition | |
| Light-weight API that mirrors the full contract from the Production Guide. | |
| Data: small synthetic sample so the demo works instantly. | |
| """ | |
| from fastapi import FastAPI, HTTPException, UploadFile, File | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| import pandas as pd | |
| import numpy as np | |
| from typing import List, Dict, Any | |
| # ------------------------------------------------------------------ | |
| # 1. tiny synthetic dataset (mirrors the schema in the Guide) | |
| # ------------------------------------------------------------------ | |
| PRECINCT_STORE: List[Dict[str, Any]] = [ | |
| { | |
| "id": "P01", | |
| "name": "Urban Ward 1", | |
| "county": "Demo County", | |
| "classification": "Urban", | |
| "registered": 3500, | |
| "latest_d_pct": 63.0, | |
| "base_turnout": 0.70, | |
| "results": { | |
| 2020: {"d_pct": 65, "turnout": 72}, | |
| 2022: {"d_pct": 60, "turnout": 50}, | |
| 2024: {"d_pct": 63, "turnout": 70}, | |
| }, | |
| "demographics": { | |
| "pct_college": 68, | |
| "pct_under_35": 40, | |
| "pct_35_64": 45, | |
| "pct_65plus": 15, | |
| "median_income": 48000, | |
| "pct_white": 55, | |
| "pct_black": 20, | |
| "pct_hispanic": 20, | |
| "pct_asian": 5, | |
| }, | |
| "centroid": [-75.47, 40.61], | |
| } | |
| for i in range(1, 11) # 10 precincts | |
| ] | |
| for p in PRECINCT_STORE: | |
| p["id"] = f"P{i:02d}" | |
| p["name"] = f"Precinct {i:02d}" | |
| p["latest_d_pct"] = 45 + np.random.normal(0, 5) # competitive | |
| p["base_turnout"] = 0.65 + np.random.normal(0, 0.05) | |
| # ------------------------------------------------------------------ | |
| # 2. FastAPI boiler-plate | |
| # ------------------------------------------------------------------ | |
| app = FastAPI(title="PoliticoAI API", version="1.0") | |
| app.mount("/", StaticFiles(directory="static", html=True), name="static") | |
| # ------------------------------------------------------------------ | |
| # 3. Models (match the Production Guide contract) | |
| # ------------------------------------------------------------------ | |
| class SimulationParams(BaseModel): | |
| iterations: int = 10000 | |
| environment: str = "neutral" | |
| polling_error: float = 3.5 | |
| turnout_variability: float = 5.0 | |
| class SegmentParams(BaseModel): | |
| primary: str = "age" | |
| secondary: str = "none" | |
| metric: str = "size" | |
| # ------------------------------------------------------------------ | |
| # 4. API endpoints (exact contract from Guide appendix) | |
| # ------------------------------------------------------------------ | |
| def health(): | |
| return {"status": "ok"} | |
| def get_precincts(): | |
| # strip heavy geometry if present, send only numbers | |
| return [ | |
| { | |
| "id": p["id"], | |
| "name": p["name"], | |
| "classification": p["classification"], | |
| "registered_voters": p["registered"], | |
| "latest_d_pct": p["latest_d_pct"], | |
| "base_turnout": p["base_turnout"], | |
| "demographics": p["demographics"], | |
| } | |
| for p in PRECINCT_STORE | |
| ] | |
| def run_monte_carlo(params: SimulationParams): | |
| env_map = {"neutral": 0, "d3": 3, "r3": -3, "d5": 5, "r5": -5} | |
| env_shift = env_map.get(params.environment, 0) | |
| margins = [] | |
| for _ in range(params.iterations): | |
| env_noise = np.random.normal(env_shift, params.polling_error) | |
| total_d, total_r = 0, 0 | |
| for pct in PRECINCT_STORE: | |
| base_d = pct["latest_d_pct"] / 100 | |
| adj_d = np.clip(base_d + env_noise / 100, 0.05, 0.95) | |
| turnout = np.clip( | |
| np.random.normal(pct["base_turnout"], params.turnout_variability / 100), | |
| 0.2, 0.95, | |
| ) | |
| votes = int(pct["registered"] * turnout) | |
| total_d += int(votes * adj_d) | |
| total_r += votes - int(votes * adj_d) | |
| margins.append((total_d - total_r) / (total_d + total_r) * 100) | |
| margins = np.array(margins) | |
| win_prob = float((margins > 0).mean() * 100) | |
| return { | |
| "win_prob": win_prob, | |
| "expected_margin": float(margins.mean()), | |
| "recount_prob": float((np.abs(margins) < 0.5).mean() * 100), | |
| "margins": margins.tolist(), | |
| "ci_95": [float(np.percentile(margins, 2.5)), float(np.percentile(margins, 97.5))], | |
| } | |
| def segment_demographics(params: SegmentParams): | |
| # lightweight demo segmentation | |
| segments = ["Under 35", "35-64", "65+"] | |
| treemap = { | |
| "labels": ["District"] + segments, | |
| "parents": [""] + ["District"] * len(segments), | |
| "values": [3500, 4000, 2500], | |
| } | |
| matrix = [ | |
| {"segment": f"Age: {seg}", "size": 3500 if seg == "Under 35" else 4000 if seg == "35-64" else 2500, | |
| "turnout": "69%", "lean": "D+2", "persuadability": "High", "strategy": "Persuade"} | |
| for seg in segments | |
| ] | |
| return {"treemap": treemap, "targeting_matrix": matrix} | |
| def analyze_precincts(): | |
| # minimal clustering demo | |
| metrics = [] | |
| for p in PRECINCT_STORE: | |
| pvi = p["latest_d_pct"] - 50 | |
| swing = np.random.uniform(1, 8) | |
| to_delta = np.random.uniform(15, 30) | |
| net = (swing + to_delta) * p["registered"] / 1000 | |
| metrics.append( | |
| { | |
| "id": p["id"], | |
| "name": p["name"], | |
| "pvi": round(pvi, 1), | |
| "swingScore": round(swing, 1), | |
| "turnoutDelta": round(to_delta, 1), | |
| "net": round(net), | |
| "tier": "High Swing" if swing > 4.5 else "Blue Base" if pvi > 0 else "Red Lean", | |
| } | |
| ) | |
| metrics = sorted(metrics, key=lambda x: x["net"], reverse=True) | |
| return {"scatter_data": metrics, "ranking": metrics[:15], "table": metrics[:20]} | |
| async def upload_results(file: UploadFile = File(...)): | |
| # accept any CSV, store in memory (or persist to disk if you like) | |
| df = pd.read_csv(file.file) | |
| # here you would validate, clean, merge into PRECINCT_STORE, etc. | |
| return {"rows": len(df), "columns": list(df.columns)} | |
| # ------------------------------------------------------------------ | |
| # 5. CORS — Spaces needs this for browser access | |
| # ------------------------------------------------------------------ | |
| from fastapi.middleware.cors import CORSMiddleware | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], # restrict in prod | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ------------------------------------------------------------------ | |
| # 6. Entry-point guard (lets uvicorn import without running) | |
| # ------------------------------------------------------------------ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |