# 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 """Sondage synthétique LLM (« silicon sampling ») — signal EXPÉRIMENTAL, poids nul. Méthode (littérature 2024-2025 : Argyle et al.; arXiv:2411.01582; critique de McKown-Dawson/Silver — « les sondages IA ne sont pas des sondages ») : 1. **Strates** : grille démographique du Québec (région × âge × genre × langue), pondérée par des poids de population (recensement, approximés). 2. **Interrogation** : pour chaque strate, le LLM (MacLustr, local) incarne un électeur type de cette strate, ancré dans l'actualité RÉELLE de la campagne (titres des 14 derniers jours issus de notre propre veille — retrieval- augmented, jamais de chiffres de sondage dans le prompt), et retourne une distribution de probabilités de vote. 3. **Poststratification** : moyenne des distributions pondérée par la population de chaque strate (MRP-lite). **Statut : expérimental, poids STRICTEMENT NUL dans le forecast.** Un LLM ne collecte aucune donnée nouvelle : c'est un modèle, pas un échantillon. On publie l'écart au forecast comme objet d'étude (le signal sera évalué après le 5 octobre), avec provenance complète (modèle, date, prompts archivés). """ from __future__ import annotations import json import logging import re from datetime import date, datetime, timedelta, timezone import httpx import numpy as np from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from .. import models as Mo log = logging.getLogger("synthetic-poll") OUT_DIR = DATA_DIR / "synthetic_polls" INDICATOR = "synthetic_poll" REFRESH_DAYS = 6 # au plus un sondage synthétique par ~semaine # Strates : (région groupée, poids pop., âge, poids âge, genre, langue, poids langue) REGIONS = [("Montréal", 0.24), ("Montérégie et Rive-Sud", 0.18), ("Québec et Chaudière-Appalaches", 0.14), ("Laval et Laurentides-Lanaudière", 0.16), ("Estrie, Centre-du-Québec et Mauricie", 0.12), ("Outaouais et Abitibi", 0.06), ("Saguenay–Lac-Saint-Jean et Côte-Nord", 0.05), ("Bas-Saint-Laurent et Gaspésie", 0.05)] AGES = [("18-34 ans", 0.26), ("35-54 ans", 0.33), ("55 ans et plus", 0.41)] GENDERS = [("femme", 0.51), ("homme", 0.49)] LANGS = [("francophone", 0.82), ("anglophone ou allophone", 0.18)] PROMPT = """Tu es un·e électeur·rice québécois·e représentatif·ve de cette strate : - Région : {region} - Âge : {age} - Genre : {gender} - Langue principale : {lang} Contexte : élection générale du Québec le 5 octobre 2026. La campagne est en cours. Actualité récente de la campagne (titres réels des derniers jours) : {headlines} Partis : CAQ (Coalition avenir Québec, Christine Fréchette, sortant après 2 mandats), PLQ (Parti libéral, Charles Milliard), PQ (Parti québécois, Paul St-Pierre Plamondon), QS (Québec solidaire, Ruba Ghazal et Sol Zanetti), PCQ (Parti conservateur, Éric Duhaime). En te basant sur ce que voterait TYPIQUEMENT l'ensemble des électeurs de cette strate (pas un individu), donne la répartition probable de leur vote. Réponds UNIQUEMENT en JSON : {{"CAQ": x, "PLQ": x, "PQ": x, "QS": x, "PCQ": x, "AUT": x}} (pourcentages, somme = 100).""" def _llm(prompt: str) -> dict | None: """Appel au LLM configuré (MacLustr par défaut) — None si indisponible.""" if not settings.maclustr_api_key: return None try: r = httpx.post(settings.maclustr_llm_url, headers={"Authorization": f"Bearer {settings.maclustr_api_key}"}, json={"model": settings.maclustr_llm_model, "temperature": 0.4, "messages": [{"role": "user", "content": prompt}], "max_tokens": 160}, timeout=45) r.raise_for_status() content = r.json()["choices"][0]["message"]["content"] m = re.search(r"\{[^{}]+\}", content, re.S) return json.loads(m.group()) if m else None except Exception as e: log.warning("llm strate: %s", e) return None def recent_headlines(db: Session, n: int = 8) -> list[str]: cutoff = datetime.now(timezone.utc) - timedelta(days=14) docs = (db.query(Mo.SentimentDocument) .filter(Mo.SentimentDocument.fetched_at >= cutoff) .order_by(Mo.SentimentDocument.published.desc().nullslast()).limit(60).all()) seen, titles = set(), [] for d in docs: k = d.cluster_key or d.url if k in seen: continue seen.add(k) titles.append(d.title.strip()) if len(titles) >= n: break return titles def strata() -> list[dict]: out = [] for reg, wr in REGIONS: for age, wa in AGES: for g, wg in GENDERS: for lang, wl in LANGS: out.append({"region": reg, "age": age, "gender": g, "lang": lang, "weight": wr * wa * wg * wl}) return out # 96 strates, somme des poids = 1 def poststratify(cells: list[dict]) -> dict[str, float]: """Moyenne des distributions de strates pondérée par la population.""" tot_w = sum(c["weight"] for c in cells) agg = {p: 0.0 for p in settings.parties} for c in cells: d = c["shares"] s = sum(max(0.0, float(d.get(p, 0.0))) for p in settings.parties) or 100.0 for p in settings.parties: agg[p] += c["weight"] / tot_w * max(0.0, float(d.get(p, 0.0))) * 100.0 / s return {p: round(v, 1) for p, v in agg.items()} def latest(db: Session) -> dict | None: row = (db.query(Mo.Indicator).filter_by(name=INDICATOR) .order_by(Mo.Indicator.as_of.desc()).first()) return ({"as_of": row.as_of.isoformat(), "shares": row.extra.get("shares"), "model": row.extra.get("model"), "n_strata": row.extra.get("n_strata"), "method": row.method, "headlines": row.extra.get("headlines"), "note": row.extra.get("note")} if row else None) def save(db: Session, shares: dict, model: str, n_strata: int, headlines: list[str], method: str = "silicon-sampling", cells: list[dict] | None = None) -> None: today = date.today() row = db.query(Mo.Indicator).filter_by(name=INDICATOR, as_of=today).first() extra = {"shares": shares, "model": model, "n_strata": n_strata, "headlines": headlines, "note": ("EXPÉRIMENTAL — poids nul dans le forecast. Un LLM ne collecte " "aucune donnée nouvelle; l'écart au forecast est publié comme " "objet d'étude (évaluation après le 5 octobre).")} if row is None: row = Mo.Indicator(name=INDICATOR, as_of=today, value=0.0) db.add(row) row.value = float(shares.get("PQ", 0.0)) # valeur repère (part PQ) row.source = f"Sondage synthétique ({model})" row.method = method row.extra = extra db.commit() try: # archive complète (provenance) OUT_DIR.mkdir(parents=True, exist_ok=True) (OUT_DIR / f"{today.isoformat()}.json").write_text(json.dumps( {"date": today.isoformat(), "model": model, "method": method, "shares": shares, "headlines": headlines, "cells": cells or []}, ensure_ascii=False, indent=1)) except Exception: pass def run(db: Session, force: bool = False) -> dict: """Génère un sondage synthétique si le précédent date de > REFRESH_DAYS.""" prev = (db.query(Mo.Indicator).filter_by(name=INDICATOR) .order_by(Mo.Indicator.as_of.desc()).first()) if prev and not force and (date.today() - prev.as_of).days < REFRESH_DAYS: return {"skipped": f"dernier sondage synthétique: {prev.as_of.isoformat()}"} heads = recent_headlines(db) head_txt = "\n".join(f"- {t}" for t in heads) or "- (aucune manchette récente)" cells, failed = [], 0 for st in strata(): d = _llm(PROMPT.format(headlines=head_txt, **st)) if d is None: failed += 1 if failed >= 5 and not cells: # endpoint mort → abandon propre return {"skipped": "LLM indisponible — sondage synthétique reporté"} continue cells.append({**st, "shares": d}) if len(cells) < 40: return {"skipped": f"trop de strates en échec ({len(cells)}/96 réussies)"} shares = poststratify(cells) save(db, shares, settings.maclustr_llm_model, len(cells), heads, cells=cells) return {"shares": shares, "n_strata": len(cells), "échecs": failed}