File size: 6,940 Bytes
f33c318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
"""
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)