SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
11.7 KB · 299 lines python
Raw Blame History
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