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"""Sondage synthétique LLM (« silicon sampling ») — signal EXPÉRIMENTAL, poids nul.67Méthode (littérature 2024-2025 : Argyle et al.; arXiv:2411.01582; critique de8McKown-Dawson/Silver — « les sondages IA ne sont pas des sondages ») :910 1. **Strates** : grille démographique du Québec (région × âge × genre × langue),11 pondérée par des poids de population (recensement, approximés).12 2. **Interrogation** : pour chaque strate, le LLM (MacLustr, local) incarne un13 électeur type de cette strate, ancré dans l'actualité RÉELLE de la campagne14 (titres des 14 derniers jours issus de notre propre veille — retrieval-15 augmented, jamais de chiffres de sondage dans le prompt), et retourne une16 distribution de probabilités de vote.17 3. **Poststratification** : moyenne des distributions pondérée par la18 population de chaque strate (MRP-lite).1920**Statut : expérimental, poids STRICTEMENT NUL dans le forecast.** Un LLM ne21collecte aucune donnée nouvelle : c'est un modèle, pas un échantillon. On publie22l'écart au forecast comme objet d'étude (le signal sera évalué après le 5 octobre),23avec provenance complète (modèle, date, prompts archivés).24"""25from __future__ import annotations2627import json28import logging29import re30from datetime import date, datetime, timedelta, timezone3132import httpx33import numpy as np34from sqlalchemy.orm import Session3536from ..config import DATA_DIR, settings37from .. import models as Mo3839log = logging.getLogger("synthetic-poll")4041OUT_DIR = DATA_DIR / "synthetic_polls"42INDICATOR = "synthetic_poll"43REFRESH_DAYS = 6 # au plus un sondage synthétique par ~semaine4445# Strates : (région groupée, poids pop., âge, poids âge, genre, langue, poids langue)46REGIONS = [("Montréal", 0.24), ("Montérégie et Rive-Sud", 0.18),47 ("Québec et Chaudière-Appalaches", 0.14), ("Laval et Laurentides-Lanaudière", 0.16),48 ("Estrie, Centre-du-Québec et Mauricie", 0.12), ("Outaouais et Abitibi", 0.06),49 ("Saguenay–Lac-Saint-Jean et Côte-Nord", 0.05), ("Bas-Saint-Laurent et Gaspésie", 0.05)]50AGES = [("18-34 ans", 0.26), ("35-54 ans", 0.33), ("55 ans et plus", 0.41)]51GENDERS = [("femme", 0.51), ("homme", 0.49)]52LANGS = [("francophone", 0.82), ("anglophone ou allophone", 0.18)]5354PROMPT = """Tu es un·e électeur·rice québécois·e représentatif·ve de cette strate :55- Région : {region}56- Âge : {age}57- Genre : {gender}58- Langue principale : {lang}5960Contexte : élection générale du Québec le 5 octobre 2026. La campagne est en cours.61Actualité récente de la campagne (titres réels des derniers jours) :62{headlines}6364Partis : CAQ (Coalition avenir Québec, Christine Fréchette, sortant après 2 mandats),65PLQ (Parti libéral, Charles Milliard), PQ (Parti québécois, Paul St-Pierre Plamondon),66QS (Québec solidaire, Ruba Ghazal et Sol Zanetti), PCQ (Parti conservateur, Éric Duhaime).6768En te basant sur ce que voterait TYPIQUEMENT l'ensemble des électeurs de cette strate69(pas un individu), donne la répartition probable de leur vote.70Réponds UNIQUEMENT en JSON : {{"CAQ": x, "PLQ": x, "PQ": x, "QS": x, "PCQ": x, "AUT": x}}71(pourcentages, somme = 100)."""727374def _llm(prompt: str) -> dict | None:75 """Appel au LLM configuré (MacLustr par défaut) — None si indisponible."""76 if not settings.maclustr_api_key:77 return None78 try:79 r = httpx.post(settings.maclustr_llm_url,80 headers={"Authorization": f"Bearer {settings.maclustr_api_key}"},81 json={"model": settings.maclustr_llm_model, "temperature": 0.4,82 "messages": [{"role": "user", "content": prompt}],83 "max_tokens": 160},84 timeout=45)85 r.raise_for_status()86 content = r.json()["choices"][0]["message"]["content"]87 m = re.search(r"\{[^{}]+\}", content, re.S)88 return json.loads(m.group()) if m else None89 except Exception as e:90 log.warning("llm strate: %s", e)91 return None929394def recent_headlines(db: Session, n: int = 8) -> list[str]:95 cutoff = datetime.now(timezone.utc) - timedelta(days=14)96 docs = (db.query(Mo.SentimentDocument)97 .filter(Mo.SentimentDocument.fetched_at >= cutoff)98 .order_by(Mo.SentimentDocument.published.desc().nullslast()).limit(60).all())99 seen, titles = set(), []100 for d in docs:101 k = d.cluster_key or d.url102 if k in seen:103 continue104 seen.add(k)105 titles.append(d.title.strip())106 if len(titles) >= n:107 break108 return titles109110111def strata() -> list[dict]:112 out = []113 for reg, wr in REGIONS:114 for age, wa in AGES:115 for g, wg in GENDERS:116 for lang, wl in LANGS:117 out.append({"region": reg, "age": age, "gender": g, "lang": lang,118 "weight": wr * wa * wg * wl})119 return out # 96 strates, somme des poids = 1120121122def poststratify(cells: list[dict]) -> dict[str, float]:123 """Moyenne des distributions de strates pondérée par la population."""124 tot_w = sum(c["weight"] for c in cells)125 agg = {p: 0.0 for p in settings.parties}126 for c in cells:127 d = c["shares"]128 s = sum(max(0.0, float(d.get(p, 0.0))) for p in settings.parties) or 100.0129 for p in settings.parties:130 agg[p] += c["weight"] / tot_w * max(0.0, float(d.get(p, 0.0))) * 100.0 / s131 return {p: round(v, 1) for p, v in agg.items()}132133134def latest(db: Session) -> dict | None:135 row = (db.query(Mo.Indicator).filter_by(name=INDICATOR)136 .order_by(Mo.Indicator.as_of.desc()).first())137 return ({"as_of": row.as_of.isoformat(), "shares": row.extra.get("shares"),138 "model": row.extra.get("model"), "n_strata": row.extra.get("n_strata"),139 "method": row.method, "headlines": row.extra.get("headlines"),140 "note": row.extra.get("note")} if row else None)141142143def save(db: Session, shares: dict, model: str, n_strata: int,144 headlines: list[str], method: str = "silicon-sampling",145 cells: list[dict] | None = None) -> None:146 today = date.today()147 row = db.query(Mo.Indicator).filter_by(name=INDICATOR, as_of=today).first()148 extra = {"shares": shares, "model": model, "n_strata": n_strata,149 "headlines": headlines,150 "note": ("EXPÉRIMENTAL — poids nul dans le forecast. Un LLM ne collecte "151 "aucune donnée nouvelle; l'écart au forecast est publié comme "152 "objet d'étude (évaluation après le 5 octobre).")}153 if row is None:154 row = Mo.Indicator(name=INDICATOR, as_of=today, value=0.0)155 db.add(row)156 row.value = float(shares.get("PQ", 0.0)) # valeur repère (part PQ)157 row.source = f"Sondage synthétique ({model})"158 row.method = method159 row.extra = extra160 db.commit()161 try: # archive complète (provenance)162 OUT_DIR.mkdir(parents=True, exist_ok=True)163 (OUT_DIR / f"{today.isoformat()}.json").write_text(json.dumps(164 {"date": today.isoformat(), "model": model, "method": method,165 "shares": shares, "headlines": headlines, "cells": cells or []},166 ensure_ascii=False, indent=1))167 except Exception:168 pass169170171def run(db: Session, force: bool = False) -> dict:172 """Génère un sondage synthétique si le précédent date de > REFRESH_DAYS."""173 prev = (db.query(Mo.Indicator).filter_by(name=INDICATOR)174 .order_by(Mo.Indicator.as_of.desc()).first())175 if prev and not force and (date.today() - prev.as_of).days < REFRESH_DAYS:176 return {"skipped": f"dernier sondage synthétique: {prev.as_of.isoformat()}"}177 heads = recent_headlines(db)178 head_txt = "\n".join(f"- {t}" for t in heads) or "- (aucune manchette récente)"179 cells, failed = [], 0180 for st in strata():181 d = _llm(PROMPT.format(headlines=head_txt, **st))182 if d is None:183 failed += 1184 if failed >= 5 and not cells: # endpoint mort → abandon propre185 return {"skipped": "LLM indisponible — sondage synthétique reporté"}186 continue187 cells.append({**st, "shares": d})188 if len(cells) < 40:189 return {"skipped": f"trop de strates en échec ({len(cells)}/96 réussies)"}190 shares = poststratify(cells)191 save(db, shares, settings.maclustr_llm_model, len(cells), heads, cells=cells)192 return {"shares": shares, "n_strata": len(cells), "échecs": failed}193