# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """API d'administration — protégée par jeton (Authorization: Bearer ).""" from __future__ import annotations from datetime import date from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from ..config import settings from ..db import get_db from .. import models as Mo from ..pipeline import run_forecast, run_pipeline from ..seed import get_or_create router = APIRouter(prefix="/api/admin") def require_admin(authorization: str = Header(default="")): if authorization != f"Bearer {settings.admin_token}": raise HTTPException(401, "Jeton d'administration invalide") class PollIn(BaseModel): pollster: str field_start: date | None = None field_end: date published: date | None = None sample_size: int | None = Field(None, ge=50, le=100000) population: str = "adults" mode: str = "unknown" moe: float | None = None sponsor: str | None = None source_name: str = "ajout manuel" source_url: str | None = None shares: dict[str, float] # {party: %} @router.post("/poll", dependencies=[Depends(require_admin)]) def add_poll(p: PollIn, db: Session = Depends(get_db)): unknown = [k for k in p.shares if k not in settings.parties] if unknown: raise HTTPException(422, f"Partis inconnus: {unknown}") if not (60 <= sum(p.shares.values()) <= 110): raise HTTPException(422, "La somme des intentions doit être plausible (60–110)") el = db.query(Mo.Election).filter_by(is_target=True).one() pollster, _ = get_or_create(db, Mo.Pollster, name=p.pollster) dupe = (db.query(Mo.Poll).filter_by(pollster_id=pollster.id, field_end=p.field_end, sample_size=p.sample_size).first()) if dupe: raise HTTPException(409, f"Doublon probable du sondage #{dupe.id}") poll = Mo.Poll(election_id=el.id, pollster_id=pollster.id, field_start=p.field_start, field_end=p.field_end, published=p.published, sample_size=p.sample_size, population=p.population, mode=p.mode, moe=p.moe, sponsor=p.sponsor, source_name=p.source_name, source_url=p.source_url) total = sum(p.shares.values()) for party, v in p.shares.items(): poll.results.append(Mo.PollResult(party=party, raw_value=v, normalized_value=round(v * 100 / total, 3))) db.add(poll) db.commit() return {"id": poll.id, "status": "créé"} class PollPatch(BaseModel): excluded: bool | None = None exclusion_reason: str | None = None sample_size: int | None = None mode: str | None = None population: str | None = None @router.patch("/poll/{poll_id}", dependencies=[Depends(require_admin)]) def patch_poll(poll_id: int, patch: PollPatch, db: Session = Depends(get_db)): poll = db.get(Mo.Poll, poll_id) if poll is None: raise HTTPException(404, "Sondage introuvable") for k, v in patch.model_dump(exclude_none=True).items(): setattr(poll, k, v) db.commit() return {"id": poll.id, "status": "modifié"} @router.post("/refresh", dependencies=[Depends(require_admin)]) def refresh(background: BackgroundTasks): background.add_task(run_pipeline, True) return {"status": "pipeline lancé en arrière-plan"} @router.post("/forecast", dependencies=[Depends(require_admin)]) def rerun_forecast(db: Session = Depends(get_db)): run = run_forecast(db) return {"run_id": run.id, "as_of": run.as_of.isoformat()} def _backtest_job(): from ..db import SessionLocal from ..modeling.backtest import run_backtest db = SessionLocal() try: run_backtest(db) finally: db.close() @router.post("/backtest", dependencies=[Depends(require_admin)]) def rerun_backtest(background: BackgroundTasks): background.add_task(_backtest_job) return {"status": "backtest lancé en arrière-plan"} @router.post("/watch", dependencies=[Depends(require_admin)]) def run_watch_now(db: Session = Depends(get_db)): """Cycle de veille Firecrawl immédiat (radar sondages, satisfaction, presse).""" from ..ingest.firecrawl_watch import run_watch return run_watch(db) class ByElectionPatch(BaseModel): used_in_model: bool | None = None result: dict[str, float] | None = None verified: bool | None = None @router.patch("/byelection/{bye_id}", dependencies=[Depends(require_admin)]) def patch_byelection(bye_id: int, patch: ByElectionPatch, db: Session = Depends(get_db)): row = db.get(Mo.ByElection, bye_id) if row is None: raise HTTPException(404, "Partielle introuvable") for k, v in patch.model_dump(exclude_none=True).items(): setattr(row, k, v) db.commit() return {"id": row.id, "status": "modifié"} class IndicatorIn(BaseModel): name: str = "gov_satisfaction" value: float = Field(ge=0, le=100) as_of: date | None = None source: str = "saisie manuelle" source_url: str | None = None @router.post("/indicator", dependencies=[Depends(require_admin)]) def set_indicator(ind: IndicatorIn, db: Session = Depends(get_db)): d = ind.as_of or date.today() row = db.query(Mo.Indicator).filter_by(name=ind.name, as_of=d).first() if row is None: row = Mo.Indicator(name=ind.name, as_of=d, value=ind.value) db.add(row) row.value = ind.value row.source = ind.source row.source_url = ind.source_url row.method = "manual" db.commit() return {"id": row.id, "status": "enregistré"} class SyntheticIn(BaseModel): """Injection d'un sondage synthétique généré hors-plateforme (amorçage).""" shares: dict[str, float] model: str n_strata: int = Field(ge=1) headlines: list[str] = Field(default_factory=list) method: str = "silicon-sampling-bootstrap" cells: list[dict] = Field(default_factory=list) @router.post("/synthetic", dependencies=[Depends(require_admin)]) def set_synthetic(s: SyntheticIn, db: Session = Depends(get_db)): unknown = [k for k in s.shares if k not in settings.parties] if unknown or not (85 <= sum(s.shares.values()) <= 110): raise HTTPException(422, "Répartition invalide") from ..modeling.synthetic_poll import save save(db, s.shares, s.model, s.n_strata, s.headlines, method=s.method, cells=s.cells) return {"status": "enregistré", "shares": s.shares} def _ablation_job(): from ..db import SessionLocal from ..modeling.validation.ablation import run_ablation db = SessionLocal() try: run_ablation(db) finally: db.close() class ReportedDistrict(BaseModel): district: str pct_reported: float = Field(ge=0, le=100) results: dict[str, float] # {party: % des votes dépouillés} class ElectionNightIn(BaseModel): reported: list[ReportedDistrict] n_sims: int = Field(15000, ge=2000, le=50000) @router.post("/election-night", dependencies=[Depends(require_admin)]) def election_night_update(body: ElectionNightIn, db: Session = Depends(get_db)): """Soir d'élection : injecte les résultats partiels DGEQ → forecast en direct.""" from ..modeling.election_night import update reported = [r.model_dump() for r in body.reported] for r in reported: s = sum(r["results"].values()) if r["results"] and not (60 <= s <= 105): raise HTTPException(422, f"{r['district']}: somme implausible ({s:.0f})") return update(db, reported, n_sims=body.n_sims) def _replay_job(): from ..modeling.validation.historical_replay import run_replay run_replay() @router.post("/replay", dependencies=[Depends(require_admin)]) def run_replay_bg(background: BackgroundTasks): """Replay multi-élections 2007-2022 (LOEO), en arrière-plan.""" background.add_task(_replay_job) return {"status": "replay historique lancé en arrière-plan"} @router.post("/ablation", dependencies=[Depends(require_admin)]) def run_ablation_bg(background: BackgroundTasks): """Ablation v3 (§22) : backtest 2022 par variante de couches, en arrière-plan.""" if not settings.enable_ablation: raise HTTPException(403, "Ablation désactivée") background.add_task(_ablation_job) return {"status": "ablation lancée en arrière-plan"} @router.get("/data-health", dependencies=[Depends(require_admin)]) def data_health(db: Session = Depends(get_db)): """Santé des données (v3 §33) : fraîcheur des sources et des acteurs.""" from datetime import datetime, timedelta, timezone now = datetime.now(timezone.utc) last_poll = db.query(Mo.Poll).order_by(Mo.Poll.accessed_at.desc()).first() last_run = (db.query(Mo.ForecastRun).filter_by(is_backtest=False) .order_by(Mo.ForecastRun.run_at.desc()).first()) last_apify = db.query(Mo.ApifyRun).order_by(Mo.ApifyRun.at.desc()).first() last_social = (db.query(Mo.SocialPost) .order_by(Mo.SocialPost.fetched_at.desc()).first()) errors_24h = (db.query(Mo.PipelineLog) .filter(Mo.PipelineLog.status == "error", Mo.PipelineLog.at >= now - timedelta(hours=24)).count()) def age_h(dt): return round((now - dt.replace(tzinfo=timezone.utc)).total_seconds() / 3600, 1) if dt else None return { "forecast": {"last_run": last_run.run_at.isoformat() if last_run else None, "age_h": age_h(last_run.run_at if last_run else None), "healthy": bool(last_run) and age_h(last_run.run_at) < 8}, "polls": {"last_accessed": last_poll.accessed_at.isoformat() if last_poll else None, "total": db.query(Mo.Poll).count()}, "apify": ({"at": last_apify.at.isoformat(), "status": last_apify.status, "items": last_apify.items_collected, "stored": last_apify.items_stored, "runtime_s": last_apify.runtime_s, "error": last_apify.error} if last_apify else None), "social_posts": {"total": db.query(Mo.SocialPost).count(), "last": last_social.fetched_at.isoformat() if last_social else None}, "web_signals": db.query(Mo.WebSignal).count(), "pipeline_errors_24h": errors_24h, } @router.get("/logs", dependencies=[Depends(require_admin)]) def logs(db: Session = Depends(get_db), limit: int = 100): rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all()) return [{"at": r.at.isoformat(), "step": r.step, "status": r.status, "message": (r.message or "")[:500]} for r in rows] @router.get("/runs", dependencies=[Depends(require_admin)]) def runs(db: Session = Depends(get_db), limit: int = 50): rows = (db.query(Mo.ForecastRun).order_by(Mo.ForecastRun.run_at.desc()).limit(limit).all()) return [{"id": r.id, "run_at": r.run_at.isoformat(), "as_of": r.as_of.isoformat(), "label": r.label, "is_backtest": r.is_backtest, "n_polls": r.n_polls_used, "n_sims": r.n_simulations, "model_version": r.model_version} for r in rows] @router.get("/sources", dependencies=[Depends(require_admin)]) def sources(db: Session = Depends(get_db)): return [{"name": s.name, "url": s.url, "kind": s.kind, "last_fetch": s.last_fetch.isoformat() if s.last_fetch else None, "last_status": s.last_status} for s in db.query(Mo.DataSource).all()] @router.get("/versions", dependencies=[Depends(require_admin)]) def versions(db: Session = Depends(get_db)): return [{"version": v.version, "released": v.released.isoformat(), "changelog": v.changelog} for v in db.query(Mo.ModelVersion).all()]