PoliticoAI_GUI / app.py
ee-in's picture
Upload 6 files
f33c318 verified
"""
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)
# ------------------------------------------------------------------
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/precincts")
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
]
@app.post("/api/simulation/montecarlo")
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))],
}
@app.post("/api/demographics/segment")
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}
@app.post("/api/precincts/analyze")
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]}
@app.post("/api/upload/results")
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)