spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""API d'administration — protégée par jeton (Authorization: Bearer <ADMIN_TOKEN>)."""6from __future__ import annotations78from datetime import date910from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException11from pydantic import BaseModel, Field12from sqlalchemy.orm import Session1314from ..config import settings15from ..db import get_db16from .. import models as Mo17from ..pipeline import run_forecast, run_pipeline18from ..seed import get_or_create1920router = APIRouter(prefix="/api/admin")212223def require_admin(authorization: str = Header(default="")):24 if authorization != f"Bearer {settings.admin_token}":25 raise HTTPException(401, "Jeton d'administration invalide")262728class PollIn(BaseModel):29 pollster: str30 field_start: date | None = None31 field_end: date32 published: date | None = None33 sample_size: int | None = Field(None, ge=50, le=100000)34 population: str = "adults"35 mode: str = "unknown"36 moe: float | None = None37 sponsor: str | None = None38 source_name: str = "ajout manuel"39 source_url: str | None = None40 shares: dict[str, float] # {party: %}414243@router.post("/poll", dependencies=[Depends(require_admin)])44def add_poll(p: PollIn, db: Session = Depends(get_db)):45 unknown = [k for k in p.shares if k not in settings.parties]46 if unknown:47 raise HTTPException(422, f"Partis inconnus: {unknown}")48 if not (60 <= sum(p.shares.values()) <= 110):49 raise HTTPException(422, "La somme des intentions doit être plausible (60–110)")50 el = db.query(Mo.Election).filter_by(is_target=True).one()51 pollster, _ = get_or_create(db, Mo.Pollster, name=p.pollster)52 dupe = (db.query(Mo.Poll).filter_by(pollster_id=pollster.id, field_end=p.field_end,53 sample_size=p.sample_size).first())54 if dupe:55 raise HTTPException(409, f"Doublon probable du sondage #{dupe.id}")56 poll = Mo.Poll(election_id=el.id, pollster_id=pollster.id, field_start=p.field_start,57 field_end=p.field_end, published=p.published, sample_size=p.sample_size,58 population=p.population, mode=p.mode, moe=p.moe, sponsor=p.sponsor,59 source_name=p.source_name, source_url=p.source_url)60 total = sum(p.shares.values())61 for party, v in p.shares.items():62 poll.results.append(Mo.PollResult(party=party, raw_value=v,63 normalized_value=round(v * 100 / total, 3)))64 db.add(poll)65 db.commit()66 return {"id": poll.id, "status": "créé"}676869class PollPatch(BaseModel):70 excluded: bool | None = None71 exclusion_reason: str | None = None72 sample_size: int | None = None73 mode: str | None = None74 population: str | None = None757677@router.patch("/poll/{poll_id}", dependencies=[Depends(require_admin)])78def patch_poll(poll_id: int, patch: PollPatch, db: Session = Depends(get_db)):79 poll = db.get(Mo.Poll, poll_id)80 if poll is None:81 raise HTTPException(404, "Sondage introuvable")82 for k, v in patch.model_dump(exclude_none=True).items():83 setattr(poll, k, v)84 db.commit()85 return {"id": poll.id, "status": "modifié"}868788@router.post("/refresh", dependencies=[Depends(require_admin)])89def refresh(background: BackgroundTasks):90 background.add_task(run_pipeline, True)91 return {"status": "pipeline lancé en arrière-plan"}929394@router.post("/forecast", dependencies=[Depends(require_admin)])95def rerun_forecast(db: Session = Depends(get_db)):96 run = run_forecast(db)97 return {"run_id": run.id, "as_of": run.as_of.isoformat()}9899100def _backtest_job():101 from ..db import SessionLocal102 from ..modeling.backtest import run_backtest103 db = SessionLocal()104 try:105 run_backtest(db)106 finally:107 db.close()108109110@router.post("/backtest", dependencies=[Depends(require_admin)])111def rerun_backtest(background: BackgroundTasks):112 background.add_task(_backtest_job)113 return {"status": "backtest lancé en arrière-plan"}114115116@router.post("/watch", dependencies=[Depends(require_admin)])117def run_watch_now(db: Session = Depends(get_db)):118 """Cycle de veille Firecrawl immédiat (radar sondages, satisfaction, presse)."""119 from ..ingest.firecrawl_watch import run_watch120 return run_watch(db)121122123class ByElectionPatch(BaseModel):124 used_in_model: bool | None = None125 result: dict[str, float] | None = None126 verified: bool | None = None127128129@router.patch("/byelection/{bye_id}", dependencies=[Depends(require_admin)])130def patch_byelection(bye_id: int, patch: ByElectionPatch, db: Session = Depends(get_db)):131 row = db.get(Mo.ByElection, bye_id)132 if row is None:133 raise HTTPException(404, "Partielle introuvable")134 for k, v in patch.model_dump(exclude_none=True).items():135 setattr(row, k, v)136 db.commit()137 return {"id": row.id, "status": "modifié"}138139140class IndicatorIn(BaseModel):141 name: str = "gov_satisfaction"142 value: float = Field(ge=0, le=100)143 as_of: date | None = None144 source: str = "saisie manuelle"145 source_url: str | None = None146147148@router.post("/indicator", dependencies=[Depends(require_admin)])149def set_indicator(ind: IndicatorIn, db: Session = Depends(get_db)):150 d = ind.as_of or date.today()151 row = db.query(Mo.Indicator).filter_by(name=ind.name, as_of=d).first()152 if row is None:153 row = Mo.Indicator(name=ind.name, as_of=d, value=ind.value)154 db.add(row)155 row.value = ind.value156 row.source = ind.source157 row.source_url = ind.source_url158 row.method = "manual"159 db.commit()160 return {"id": row.id, "status": "enregistré"}161162163class SyntheticIn(BaseModel):164 """Injection d'un sondage synthétique généré hors-plateforme (amorçage)."""165 shares: dict[str, float]166 model: str167 n_strata: int = Field(ge=1)168 headlines: list[str] = Field(default_factory=list)169 method: str = "silicon-sampling-bootstrap"170 cells: list[dict] = Field(default_factory=list)171172173@router.post("/synthetic", dependencies=[Depends(require_admin)])174def set_synthetic(s: SyntheticIn, db: Session = Depends(get_db)):175 unknown = [k for k in s.shares if k not in settings.parties]176 if unknown or not (85 <= sum(s.shares.values()) <= 110):177 raise HTTPException(422, "Répartition invalide")178 from ..modeling.synthetic_poll import save179 save(db, s.shares, s.model, s.n_strata, s.headlines, method=s.method,180 cells=s.cells)181 return {"status": "enregistré", "shares": s.shares}182183184def _ablation_job():185 from ..db import SessionLocal186 from ..modeling.validation.ablation import run_ablation187 db = SessionLocal()188 try:189 run_ablation(db)190 finally:191 db.close()192193194class ReportedDistrict(BaseModel):195 district: str196 pct_reported: float = Field(ge=0, le=100)197 results: dict[str, float] # {party: % des votes dépouillés}198199200class ElectionNightIn(BaseModel):201 reported: list[ReportedDistrict]202 n_sims: int = Field(15000, ge=2000, le=50000)203204205@router.post("/election-night", dependencies=[Depends(require_admin)])206def election_night_update(body: ElectionNightIn, db: Session = Depends(get_db)):207 """Soir d'élection : injecte les résultats partiels DGEQ → forecast en direct."""208 from ..modeling.election_night import update209 reported = [r.model_dump() for r in body.reported]210 for r in reported:211 s = sum(r["results"].values())212 if r["results"] and not (60 <= s <= 105):213 raise HTTPException(422, f"{r['district']}: somme implausible ({s:.0f})")214 return update(db, reported, n_sims=body.n_sims)215216217def _replay_job():218 from ..modeling.validation.historical_replay import run_replay219 run_replay()220221222@router.post("/replay", dependencies=[Depends(require_admin)])223def run_replay_bg(background: BackgroundTasks):224 """Replay multi-élections 2007-2022 (LOEO), en arrière-plan."""225 background.add_task(_replay_job)226 return {"status": "replay historique lancé en arrière-plan"}227228229@router.post("/ablation", dependencies=[Depends(require_admin)])230def run_ablation_bg(background: BackgroundTasks):231 """Ablation v3 (§22) : backtest 2022 par variante de couches, en arrière-plan."""232 if not settings.enable_ablation:233 raise HTTPException(403, "Ablation désactivée")234 background.add_task(_ablation_job)235 return {"status": "ablation lancée en arrière-plan"}236237238@router.get("/data-health", dependencies=[Depends(require_admin)])239def data_health(db: Session = Depends(get_db)):240 """Santé des données (v3 §33) : fraîcheur des sources et des acteurs."""241 from datetime import datetime, timedelta, timezone242 now = datetime.now(timezone.utc)243 last_poll = db.query(Mo.Poll).order_by(Mo.Poll.accessed_at.desc()).first()244 last_run = (db.query(Mo.ForecastRun).filter_by(is_backtest=False)245 .order_by(Mo.ForecastRun.run_at.desc()).first())246 last_apify = db.query(Mo.ApifyRun).order_by(Mo.ApifyRun.at.desc()).first()247 last_social = (db.query(Mo.SocialPost)248 .order_by(Mo.SocialPost.fetched_at.desc()).first())249 errors_24h = (db.query(Mo.PipelineLog)250 .filter(Mo.PipelineLog.status == "error",251 Mo.PipelineLog.at >= now - timedelta(hours=24)).count())252 def age_h(dt):253 return round((now - dt.replace(tzinfo=timezone.utc)).total_seconds() / 3600, 1) if dt else None254 return {255 "forecast": {"last_run": last_run.run_at.isoformat() if last_run else None,256 "age_h": age_h(last_run.run_at if last_run else None),257 "healthy": bool(last_run) and age_h(last_run.run_at) < 8},258 "polls": {"last_accessed": last_poll.accessed_at.isoformat() if last_poll else None,259 "total": db.query(Mo.Poll).count()},260 "apify": ({"at": last_apify.at.isoformat(), "status": last_apify.status,261 "items": last_apify.items_collected,262 "stored": last_apify.items_stored,263 "runtime_s": last_apify.runtime_s,264 "error": last_apify.error} if last_apify else None),265 "social_posts": {"total": db.query(Mo.SocialPost).count(),266 "last": last_social.fetched_at.isoformat() if last_social else None},267 "web_signals": db.query(Mo.WebSignal).count(),268 "pipeline_errors_24h": errors_24h,269 }270271272@router.get("/logs", dependencies=[Depends(require_admin)])273def logs(db: Session = Depends(get_db), limit: int = 100):274 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all())275 return [{"at": r.at.isoformat(), "step": r.step, "status": r.status,276 "message": (r.message or "")[:500]} for r in rows]277278279@router.get("/runs", dependencies=[Depends(require_admin)])280def runs(db: Session = Depends(get_db), limit: int = 50):281 rows = (db.query(Mo.ForecastRun).order_by(Mo.ForecastRun.run_at.desc()).limit(limit).all())282 return [{"id": r.id, "run_at": r.run_at.isoformat(), "as_of": r.as_of.isoformat(),283 "label": r.label, "is_backtest": r.is_backtest,284 "n_polls": r.n_polls_used, "n_sims": r.n_simulations,285 "model_version": r.model_version} for r in rows]286287288@router.get("/sources", dependencies=[Depends(require_admin)])289def sources(db: Session = Depends(get_db)):290 return [{"name": s.name, "url": s.url, "kind": s.kind,291 "last_fetch": s.last_fetch.isoformat() if s.last_fetch else None,292 "last_status": s.last_status} for s in db.query(Mo.DataSource).all()]293294295@router.get("/versions", dependencies=[Depends(require_admin)])296def versions(db: Session = Depends(get_db)):297 return [{"version": v.version, "released": v.released.isoformat(),298 "changelog": v.changelog} for v in db.query(Mo.ModelVersion).all()]299