SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
19 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%

v2.1.0 — signaux web avancés + « Le point du jour »

- Attention Wikipédia (pageviews partis+chefs, API Wikimedia) : parts 7 j/28 j,
  z-scores; détecteur de turbulence → variance de campagne ×≤1,45 (jamais la moyenne)
  [Smith & Gustafson POQ 2017]
- Sondage synthétique LLM « silicon sampling » : 96 strates région×âge×genre×langue
  poststratifiées, ancré dans les manchettes réelles — EXPÉRIMENTAL, poids nul
  [arXiv:2411.01582; critique McKown-Dawson 2025]
- Page quotidienne /aujourdhui : forecast daté, Δ vs veille, journal 24 h (sondages
  intégrés + radar), attention web avec sparklines, synthétique vs forecast
- API : /api/today, /api/attention, /api/synthetic; admin POST /synthetic (amorçage)
- forecast(drift_multiplier) : turbulence élargit l'incertitude seulement
- 24 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 24 days ago (Aug 30, 2026) parent 03c9ead

15 changed files +2,291 −9

modified METHODOLOGY.md +20 −0
@@ -101,6 +101,26 @@ sondages dans la presse — alerte avec provenance, aucune ingestion automatique
101 101 chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie
102 102 au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`).
103 103
104 +**Signaux web avancés (v2.1)** :
105 +- **Attention Wikipédia** (API Wikimedia, pageviews des pages des partis et chef·fe·s,
106 + fr.wikipédia) — ajoute du pouvoir prédictif au-delà des sondages+fondamentaux
107 + (Smith & Gustafson, *POQ* 2017; Salem & Stephany 2020). Signal **jamais
108 + directionnel** : un pic global d'attention (z ≥ 2 sur 3 j vs base 180 j) gonfle la
109 + variance de dérive du forecast (multiplicateur borné ≤ 1,45), sans toucher aucune
110 + moyenne. Parts d'attention 7 j/28 j publiées (`/api/attention`).
111 +- **Sondage synthétique LLM** (« silicon sampling », arXiv:2411.01582) — 96 strates
112 + démographiques (région×âge×genre×langue, poids de population), LLM incarnant chaque
113 + strate, ancré dans les manchettes réelles de la veille (jamais de chiffres de
114 + sondage dans le prompt), poststratification. **EXPÉRIMENTAL, poids strictement
115 + nul** (critique McKown-Dawson 2025 : un LLM ne collecte aucune donnée nouvelle);
116 + écart au forecast publié comme objet d'étude, évaluation après le 5 octobre.
117 + Provenance complète archivée (`data/synthetic_polls/`).
118 +
119 +**Page quotidienne** : `/aujourdhui` (« Le point du jour ») — snapshot daté :
120 +probabilités publiées, Δ vs dernier run de la veille, journal 24 h (sondages intégrés,
121 +détections du radar), attention web, sondage synthétique, état des couches
122 +(`/api/today`).
123 +
104 124 **Décomposition** : à chaque run, le forecast est reconstruit couche par couche
105 125 (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par
106 126 variante; les Δ de vote et de probabilité de chaque couche sont publiés.
modified backend/app/api/admin.py +21 −0
@@ -160,6 +160,27 @@ def set_indicator(ind: IndicatorIn, db: Session = Depends(get_db)):
160 160 return {"id": row.id, "status": "enregistré"}
161 161
162 162
163 +class SyntheticIn(BaseModel):
164 + """Injection d'un sondage synthétique généré hors-plateforme (amorçage)."""
165 + shares: dict[str, float]
166 + model: str
167 + 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)
171 +
172 +
173 +@router.post("/synthetic", dependencies=[Depends(require_admin)])
174 +def 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 save
179 + 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}
182 +
183 +
163 184 @router.get("/logs", dependencies=[Depends(require_admin)])
164 185 def logs(db: Session = Depends(get_db), limit: int = 100):
165 186 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all())
modified backend/app/api/public.py +95 −0
@@ -219,6 +219,101 @@ def signals(db: Session = Depends(get_db), kind: str | None = None,
219 219 for s in rows]}
220 220
221 221
222 +@router.get("/attention")
223 +def attention(db: Session = Depends(get_db)):
224 + """Attention Wikipédia : parts, tendances, turbulence (signal non directionnel)."""
225 + from ..ingest.wiki_attention import signal
226 + return signal(db)
227 +
228 +
229 +@router.get("/synthetic")
230 +def synthetic(db: Session = Depends(get_db)):
231 + """Dernier sondage synthétique LLM — EXPÉRIMENTAL, poids nul dans le forecast."""
232 + from ..modeling.synthetic_poll import latest
233 + data = latest(db)
234 + if data is None:
235 + raise HTTPException(404, "Aucun sondage synthétique généré pour l'instant")
236 + try:
237 + run = _latest_run(db)
238 + data["forecast_comparison"] = {
239 + p: {"synthetique": data["shares"].get(p),
240 + "forecast": run.national["forecast"][p]["mean"]}
241 + for p in settings.parties if data.get("shares")}
242 + except HTTPException:
243 + pass
244 + return data
245 +
246 +
247 +@router.get("/today")
248 +def today(db: Session = Depends(get_db)):
249 + """« Le point du jour » : forecast daté + ce qui a changé depuis la veille."""
250 + run = _latest_run(db)
251 + # dernier run d'une journée antérieure (comparaison « depuis hier »)
252 + prev = (db.query(Mo.ForecastRun)
253 + .filter(Mo.ForecastRun.is_backtest.is_(False),
254 + Mo.ForecastRun.as_of < run.as_of)
255 + .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first())
256 +
257 + def probs(r):
258 + ens = (r.seats.get("ensemble") or {}).get("blended") if r.seats else None
259 + return {p: (ens or {}).get(p, r.seats["per_party"][p]["prob_most"])
260 + for p in settings.parties}
261 +
262 + cur_p, cur_v = probs(run), {p: run.national["forecast"][p]["mean"]
263 + for p in settings.parties}
264 + cur_s = {p: run.seats["per_party"][p]["mean"] for p in settings.parties}
265 + delta = None
266 + if prev:
267 + pv = {p: prev.national["forecast"][p]["mean"] for p in settings.parties}
268 + pp = probs(prev)
269 + ps = {p: prev.seats["per_party"][p]["mean"] for p in settings.parties}
270 + delta = {"since": prev.as_of.isoformat(),
271 + "prob_most": {p: round(cur_p[p] - pp[p], 4) for p in settings.parties},
272 + "vote": {p: round(cur_v[p] - pv[p], 2) for p in settings.parties},
273 + "seats": {p: round(cur_s[p] - ps[p], 1) for p in settings.parties}}
274 +
275 + # nouveautés des dernières 24 h
276 + day_ago = datetime.now(timezone.utc) - timedelta(hours=24)
277 + new_polls = (db.query(Mo.Poll).filter(Mo.Poll.accessed_at >= day_ago)
278 + .order_by(Mo.Poll.field_end.desc()).limit(10).all())
279 + new_signals = (db.query(Mo.WebSignal).filter(Mo.WebSignal.detected_at >= day_ago)
280 + .order_by(Mo.WebSignal.detected_at.desc()).limit(12).all())
281 + upcoming = (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date >= date.today())
282 + .order_by(Mo.NewsEvent.event_date.asc()).limit(5).all())
283 +
284 + target = db.query(Mo.Election).filter_by(is_target=True).first()
285 + B = run.national.get("beyond") or {}
286 + return {
287 + "date": date.today().isoformat(),
288 + "as_of": run.as_of.isoformat(), "run_id": run.id,
289 + "run_at": run.run_at.isoformat(), "model_version": run.model_version,
290 + "days_to_election": (target.election_date - date.today()).days if target else None,
291 + "prob_most": cur_p, "vote": cur_v,
292 + "seats": {p: {"mean": cur_s[p],
293 + "p05": run.seats["per_party"][p]["p05"],
294 + "p95": run.seats["per_party"][p]["p95"],
295 + "prob_majority": run.seats["per_party"][p]["prob_majority"]}
296 + for p in settings.parties},
297 + "prob_no_majority": run.seats["summary"]["prob_no_majority"],
298 + "delta": delta,
299 + "beyond": {"web_attention": B.get("web_attention"),
300 + "media_adjustment": B.get("media_adjustment"),
301 + "market_ensemble": B.get("market_ensemble"),
302 + "fundamentals": {"satisfaction": (B.get("fundamentals") or {}).get("satisfaction"),
303 + "blend": (B.get("fundamentals") or {}).get("blend")},
304 + "n_byelections": len(B.get("byelections_used") or [])},
305 + "new_polls_24h": [{"pollster": p.pollster.name,
306 + "field_end": p.field_end.isoformat(),
307 + "sample_size": p.sample_size,
308 + "shares": {r.party: r.normalized_value for r in p.results}}
309 + for p in new_polls],
310 + "new_signals_24h": [{"kind": s.kind, "title": s.title, "url": s.url,
311 + "pollster": s.pollster} for s in new_signals],
312 + "upcoming_events": [{"date": e.event_date.isoformat(), "title": e.title,
313 + "kind": e.kind} for e in upcoming],
314 + }
315 +
316 +
222 317 @router.get("/polls")
223 318 def polls(db: Session = Depends(get_db),
224 319 date_from: date | None = None, date_to: date | None = None,
modified backend/app/config.py +1 −1
@@ -36,7 +36,7 @@ _load_env_file(BASE_DIR / ".env")
36 36 class Settings(BaseSettings):
37 37 # --- Général ---
38 38 app_name: str = "QC Élection Forecast"
39 model_version: str = "2.0.0"
39 + model_version: str = "2.1.0"
40 40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"
41 41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")
42 42 port: int = 8140
added backend/app/ingest/wiki_attention.py +158 −0
@@ -0,0 +1,158 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Attention Wikipédia — comportement de recherche d'information (v2.1).
6 +
7 +Littérature : les pageviews Wikipédia ajoutent du pouvoir prédictif AU-DELÀ des
8 +sondages et des fondamentaux (Smith & Gustafson, *Public Opinion Quarterly* 2017;
9 +Salem & Stephany 2020 — « challenger's best friend ») : contrairement aux réseaux
10 +sociaux, consulter une page est une recherche d'information privée, sans mécanisme
11 +de rétroaction sociale, et l'attention se porte de façon disproportionnée vers les
12 +partis en MOUVEMENT (gains de votes), surtout les challengers.
13 +
14 +Utilisation (prudente, jamais directionnelle) :
15 + 1. **Signal affiché** : parts d'attention 7 j / 28 j par parti (page du parti +
16 + pages des chef·fe·s, fr.wikipédia), tendance vs base de 180 j.
17 + 2. **Détecteur de turbulence** : un pic d'attention global (z ≥ seuil) signale
18 + un moment chaud de campagne → la variance de dérive du forecast est GONFLÉE
19 + (multiplicateur borné ≤ 1,45). L'attention ne déplace jamais la moyenne —
20 + un pic peut être bon ou mauvais pour un parti; il annonce du mouvement.
21 +
22 +Source : API REST Wikimedia (pageviews per-article), publique, sans clé.
23 +"""
24 +from __future__ import annotations
25 +
26 +import logging
27 +from datetime import date, timedelta
28 +
29 +import httpx
30 +import numpy as np
31 +from sqlalchemy.orm import Session
32 +
33 +from ..config import settings
34 +from .. import models as Mo
35 +
36 +log = logging.getLogger("wiki-attention")
37 +
38 +WM_API = ("https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/"
39 + "fr.wikipedia/all-access/user/{article}/daily/{start}/{end}")
40 +UA = {"User-Agent": "qc-election-forecast/2.1 (https://www.qc-election.com; "
41 + "contact@spboucher.ai)"}
42 +
43 +# Pages suivies : parti + chef·fe·s (fr.wikipédia)
44 +ARTICLES: dict[str, list[str]] = {
45 + "CAQ": ["Coalition_avenir_Québec", "Christine_Fréchette"],
46 + "PLQ": ["Parti_libéral_du_Québec", "Charles_Milliard"],
47 + "PQ": ["Parti_québécois", "Paul_St-Pierre_Plamondon"],
48 + "QS": ["Québec_solidaire", "Ruba_Ghazal", "Sol_Zanetti"],
49 + "PCQ": ["Parti_conservateur_du_Québec", "Éric_Duhaime"],
50 +}
51 +
52 +BASELINE_DAYS = 180
53 +INDICATOR = "wiki_att_{party}"
54 +
55 +
56 +def _fetch_article(article: str, start: date, end: date) -> dict[date, int]:
57 + from urllib.parse import quote
58 + url = WM_API.format(article=quote(article, safe=""),
59 + start=start.strftime("%Y%m%d"), end=end.strftime("%Y%m%d"))
60 + r = httpx.get(url, headers=UA, timeout=30)
61 + if r.status_code == 404: # article sans données sur la période
62 + return {}
63 + r.raise_for_status()
64 + out = {}
65 + for item in r.json().get("items", []):
66 + d = date(int(item["timestamp"][:4]), int(item["timestamp"][4:6]),
67 + int(item["timestamp"][6:8]))
68 + out[d] = int(item["views"])
69 + return out
70 +
71 +
72 +def refresh(db: Session, days: int = BASELINE_DAYS + 40) -> dict:
73 + """Récupère et upsert les pageviews quotidiens (parti + chefs agrégés)."""
74 + end = date.today() - timedelta(days=1) # la veille : journée complète
75 + # reprise incrémentale : depuis le dernier point stocké (moins 3 j de marge)
76 + last = (db.query(Mo.Indicator)
77 + .filter(Mo.Indicator.name.like("wiki_att_%"))
78 + .order_by(Mo.Indicator.as_of.desc()).first())
79 + start = max(end - timedelta(days=days),
80 + (last.as_of - timedelta(days=3)) if last else end - timedelta(days=days))
81 + added = 0
82 + for party, arts in ARTICLES.items():
83 + daily: dict[date, int] = {}
84 + for a in arts:
85 + try:
86 + for d, v in _fetch_article(a, start, end).items():
87 + daily[d] = daily.get(d, 0) + v
88 + except Exception as e:
89 + log.warning("pageviews %s: %s", a, e)
90 + name = INDICATOR.format(party=party)
91 + for d, v in daily.items():
92 + row = db.query(Mo.Indicator).filter_by(name=name, as_of=d).first()
93 + if row is None:
94 + db.add(Mo.Indicator(name=name, as_of=d, value=float(v),
95 + source="Wikimedia pageviews (fr)",
96 + source_url="https://wikimedia.org/api/rest_v1/",
97 + method="wikimedia-api",
98 + extra={"articles": arts}))
99 + added += 1
100 + else:
101 + row.value = float(v)
102 + db.commit()
103 + return {"points_ajoutés": added, "fenêtre": [start.isoformat(), end.isoformat()]}
104 +
105 +
106 +def signal(db: Session) -> dict:
107 + """Parts d'attention, tendances et détecteur de turbulence.
108 +
109 + Retourne aussi `drift_multiplier` ∈ [1, 1,45] : gonfle la variance de dérive
110 + du forecast quand l'attention totale est anormalement élevée (z ≥ 2)."""
111 + end = date.today() - timedelta(days=1)
112 + start = end - timedelta(days=BASELINE_DAYS)
113 + series: dict[str, dict[date, float]] = {}
114 + for party in ARTICLES:
115 + rows = (db.query(Mo.Indicator)
116 + .filter(Mo.Indicator.name == INDICATOR.format(party=party),
117 + Mo.Indicator.as_of >= start).all())
118 + series[party] = {r.as_of: r.value for r in rows}
119 + all_days = sorted(set().union(*[set(s) for s in series.values()])) if series else []
120 + if len(all_days) < 30:
121 + return {"available": False, "drift_multiplier": 1.0,
122 + "note": "historique d'attention insuffisant (premier cycle)"}
123 +
124 + def win_sum(party, n):
125 + cut = end - timedelta(days=n)
126 + return sum(v for d, v in series[party].items() if d > cut)
127 +
128 + parties = list(ARTICLES)
129 + tot7 = {p: win_sum(p, 7) for p in parties}
130 + tot28 = {p: win_sum(p, 28) for p in parties}
131 + base = {p: win_sum(p, BASELINE_DAYS) for p in parties}
132 + s7, s28, sbase = sum(tot7.values()), sum(tot28.values()), sum(base.values())
133 +
134 + out_p, z_max = {}, 0.0
135 + for p in parties:
136 + vals = np.array([series[p].get(d, 0.0) for d in all_days])
137 + mu, sd = float(vals[:-7].mean()), float(vals[:-7].std() or 1.0)
138 + z_recent = float((np.mean(vals[-3:]) - mu) / sd)
139 + z_max = max(z_max, z_recent)
140 + share7 = tot7[p] / s7 if s7 else 0.0
141 + share_base = base[p] / sbase if sbase else 0.0
142 + out_p[p] = {
143 + "views_7j": int(tot7[p]),
144 + "share_7j": round(share7 * 100, 1),
145 + "share_28j": round((tot28[p] / s28 if s28 else 0) * 100, 1),
146 + "share_base_180j": round(share_base * 100, 1),
147 + "delta_share_pp": round((share7 - share_base) * 100, 1),
148 + "z_3j": round(z_recent, 2),
149 + "sparkline": [int(v) for v in vals[-28:]],
150 + }
151 + # turbulence : pic d'attention global → variance de campagne gonflée (borné)
152 + mult = float(np.clip(1.0 + 0.15 * max(0.0, z_max - 2.0), 1.0, 1.45))
153 + return {"available": True, "parties": out_p, "z_max": round(z_max, 2),
154 + "drift_multiplier": round(mult, 3),
155 + "as_of": end.isoformat(), "baseline_days": BASELINE_DAYS,
156 + "note": ("Signal d'attention (jamais directionnel) : un pic global "
157 + "gonfle l'incertitude de campagne, ne déplace aucune moyenne."),
158 + "sparkline_dates": [d.isoformat() for d in all_days[-28:]]}
modified backend/app/main.py +2 −2
@@ -78,8 +78,8 @@ def health():
78 78 return {"status": "ok", "app": settings.app_name, "version": settings.model_version}
79 79
80 80
81 PAGES = ["index", "carte", "sondages", "signaux", "simulateur", "intelligence",
82 "methodologie", "backtest", "admin"]
81 +PAGES = ["index", "aujourdhui", "carte", "sondages", "signaux", "simulateur",
82 + "intelligence", "methodologie", "backtest", "admin"]
83 83 for page in PAGES:
84 84 path = "/" if page == "index" else f"/{page}"
85 85
modified backend/app/modeling/forecast.py +5 −2
@@ -58,10 +58,13 @@ def nowcast(trend: TrendResult, as_of: date,
58 58
59 59
60 60 def forecast(trend: TrendResult, as_of: date, election_day: date,
61 rng: np.random.Generator | None = None) -> VoteDistribution:
61 + rng: np.random.Generator | None = None,
62 + drift_multiplier: float = 1.0) -> VoteDistribution:
63 + """drift_multiplier ∈ [1, 1,45] : turbulence détectée (pic d'attention web)
64 + → l'incertitude d'évolution future est gonflée, la moyenne jamais touchée."""
62 65 rng = rng or np.random.default_rng(2)
63 66 days = max(0, (election_day - as_of).days)
64 drift_var = trend.q * days * settings.campaign_drift_inflation
67 + drift_var = trend.q * days * settings.campaign_drift_inflation * drift_multiplier
65 68 # + erreur systémique de sondage (corrélée entre partis via la composition) :
66 69 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp.
67 70 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M)
added backend/app/modeling/synthetic_poll.py +192 −0
@@ -0,0 +1,192 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Sondage synthétique LLM (« silicon sampling ») — signal EXPÉRIMENTAL, poids nul.
6 +
7 +Méthode (littérature 2024-2025 : Argyle et al.; arXiv:2411.01582; critique de
8 +McKown-Dawson/Silver — « les sondages IA ne sont pas des sondages ») :
9 +
10 + 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 un
13 + électeur type de cette strate, ancré dans l'actualité RÉELLE de la campagne
14 + (titres des 14 derniers jours issus de notre propre veille — retrieval-
15 + augmented, jamais de chiffres de sondage dans le prompt), et retourne une
16 + distribution de probabilités de vote.
17 + 3. **Poststratification** : moyenne des distributions pondérée par la
18 + population de chaque strate (MRP-lite).
19 +
20 +**Statut : expérimental, poids STRICTEMENT NUL dans le forecast.** Un LLM ne
21 +collecte aucune donnée nouvelle : c'est un modèle, pas un échantillon. On publie
22 +l'écart au forecast comme objet d'étude (le signal sera évalué après le 5 octobre),
23 +avec provenance complète (modèle, date, prompts archivés).
24 +"""
25 +from __future__ import annotations
26 +
27 +import json
28 +import logging
29 +import re
30 +from datetime import date, datetime, timedelta, timezone
31 +
32 +import httpx
33 +import numpy as np
34 +from sqlalchemy.orm import Session
35 +
36 +from ..config import DATA_DIR, settings
37 +from .. import models as Mo
38 +
39 +log = logging.getLogger("synthetic-poll")
40 +
41 +OUT_DIR = DATA_DIR / "synthetic_polls"
42 +INDICATOR = "synthetic_poll"
43 +REFRESH_DAYS = 6 # au plus un sondage synthétique par ~semaine
44 +
45 +# Strates : (région groupée, poids pop., âge, poids âge, genre, langue, poids langue)
46 +REGIONS = [("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)]
50 +AGES = [("18-34 ans", 0.26), ("35-54 ans", 0.33), ("55 ans et plus", 0.41)]
51 +GENDERS = [("femme", 0.51), ("homme", 0.49)]
52 +LANGS = [("francophone", 0.82), ("anglophone ou allophone", 0.18)]
53 +
54 +PROMPT = """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}
59 +
60 +Contexte : élection générale du Québec le 5 octobre 2026. La campagne est en cours.
61 +Actualité récente de la campagne (titres réels des derniers jours) :
62 +{headlines}
63 +
64 +Partis : CAQ (Coalition avenir Québec, Christine Fréchette, sortant après 2 mandats),
65 +PLQ (Parti libéral, Charles Milliard), PQ (Parti québécois, Paul St-Pierre Plamondon),
66 +QS (Québec solidaire, Ruba Ghazal et Sol Zanetti), PCQ (Parti conservateur, Éric Duhaime).
67 +
68 +En te basant sur ce que voterait TYPIQUEMENT l'ensemble des électeurs de cette strate
69 +(pas un individu), donne la répartition probable de leur vote.
70 +Réponds UNIQUEMENT en JSON : {{"CAQ": x, "PLQ": x, "PQ": x, "QS": x, "PCQ": x, "AUT": x}}
71 +(pourcentages, somme = 100)."""
72 +
73 +
74 +def _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 None
78 + 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 None
89 + except Exception as e:
90 + log.warning("llm strate: %s", e)
91 + return None
92 +
93 +
94 +def 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.url
102 + if k in seen:
103 + continue
104 + seen.add(k)
105 + titles.append(d.title.strip())
106 + if len(titles) >= n:
107 + break
108 + return titles
109 +
110 +
111 +def 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 = 1
120 +
121 +
122 +def 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.0
129 + for p in settings.parties:
130 + agg[p] += c["weight"] / tot_w * max(0.0, float(d.get(p, 0.0))) * 100.0 / s
131 + return {p: round(v, 1) for p, v in agg.items()}
132 +
133 +
134 +def 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)
141 +
142 +
143 +def 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 = method
159 + row.extra = extra
160 + 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 + pass
169 +
170 +
171 +def 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 = [], 0
180 + for st in strata():
181 + d = _llm(PROMPT.format(headlines=head_txt, **st))
182 + if d is None:
183 + failed += 1
184 + if failed >= 5 and not cells: # endpoint mort → abandon propre
185 + return {"skipped": "LLM indisponible — sondage synthétique reporté"}
186 + continue
187 + 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}
modified backend/app/pipeline.py +29 −2
@@ -116,8 +116,19 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non
116 116 if trend is None:
117 117 raise RuntimeError("Pas assez de sondages pour ajuster le modèle")
118 118
119 + # --- attention Wikipédia : détecteur de turbulence (variance seulement) ---
120 + attention: dict = {"available": False, "drift_multiplier": 1.0}
121 + if not is_backtest:
122 + try:
123 + from .ingest.wiki_attention import signal as att_signal
124 + attention = att_signal(db)
125 + except Exception:
126 + pass
127 + drift_mult = float(attention.get("drift_multiplier", 1.0))
128 +
119 129 now_d = nc(trend, as_of)
120 fc_b = fc(trend, as_of, target.election_date) # sondages + partielles
130 + fc_b = fc(trend, as_of, target.election_date,
131 + drift_multiplier=drift_mult) # sondages + partielles
121 132
122 133 # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) ---
123 134 result_2022 = (db.query(Mo.Election)
@@ -151,7 +162,7 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non
151 162 s = SIM.run_simulation(inp, n_sims=8000,
152 163 rng=np.random.default_rng(seed))
153 164 return {p: s.seats[p]["prob_most"] for p in settings.parties}
154 fc_a = fc(trend_a, as_of, target.election_date)
165 + fc_a = fc(trend_a, as_of, target.election_date, drift_multiplier=drift_mult)
155 166 variants = [
156 167 ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P),
157 168 ("partielles", "+ votes réels des partielles", fc_b.x, fc_b.P),
@@ -207,6 +218,7 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non
207 218
208 219 beyond = {
209 220 "decomposition": decomposition,
221 + "web_attention": {k: v for k, v in attention.items() if k != "sparkline_dates"},
210 222 "fundamentals": fund_diag,
211 223 "byelections_used": [{"pollster": b["pollster"],
212 224 "field_end": b["field_end"].isoformat(),
@@ -293,6 +305,21 @@ def run_pipeline(full_refresh: bool = True) -> dict:
293 305 except Exception as e:
294 306 report["veille"] = f"échec: {e}"
295 307 _log(db, "veille-firecrawl", "error", traceback.format_exc())
308 + try:
309 + from .ingest.wiki_attention import refresh as att_refresh
310 + report["attention"] = att_refresh(db)
311 + _log(db, "attention-wiki", "ok", str(report["attention"])[:500])
312 + except Exception as e:
313 + report["attention"] = f"échec: {e}"
314 + _log(db, "attention-wiki", "error", traceback.format_exc())
315 + try:
316 + from .modeling.synthetic_poll import run as synth_run
317 + report["sondage_synthétique"] = synth_run(db)
318 + _log(db, "sondage-synthetique", "ok",
319 + str(report["sondage_synthétique"])[:500])
320 + except Exception as e:
321 + report["sondage_synthétique"] = f"échec: {e}"
322 + _log(db, "sondage-synthetique", "error", traceback.format_exc())
296 323 try:
297 324 run = run_forecast(db)
298 325 report["forecast_run_id"] = run.id
modified backend/app/seed.py +9 −0
@@ -149,6 +149,15 @@ def seed_all(db: Session | None = None) -> dict:
149 149 "88/12 avec Polymarket sur P(plus de sièges), veille web continue "
150 150 "Firecrawl (radar sondages + satisfaction + presse élargie), "
151 151 "décomposition du forecast par couche, backtest comparatif v2.")))
152 + get_or_create(db, Mo.ModelVersion, version="2.1.0",
153 + defaults=dict(changelog=(
154 + "Signaux web avancés : attention Wikipédia (pageviews partis+chefs, "
155 + "littérature Smith & Gustafson 2017) → détecteur de turbulence qui "
156 + "gonfle la variance de campagne (×≤1,45, jamais la moyenne); sondage "
157 + "synthétique LLM (« silicon sampling », 96 strates poststratifiées, "
158 + "ancré dans l'actualité réelle) — EXPÉRIMENTAL, poids nul; page "
159 + "quotidienne « Le point du jour » (/aujourdhui, API /api/today) : "
160 + "forecast daté, Δ vs veille, journal 24 h.")))
152 161 # Sources
153 162 for name, url, kind in [
154 163 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"),
added backend/data/synthetic_polls/2026-08-30.json +1465 −0
@@ -0,0 +1,1465 @@
1 +{
2 + "date": "2026-08-30",
3 + "model": "claude-sonnet-4-6",
4 + "method": "silicon-sampling-bootstrap",
5 + "shares": {
6 + "CAQ": 20.1,
7 + "PCQ": 9.9,
8 + "PLQ": 17.5,
9 + "PQ": 25.5,
10 + "QS": 19.0,
11 + "AUT": 8.0
12 + },
13 + "headlines": [
14 + "Élections fédérales partielles | Les électeurs se rendront aux urnes lundi dans trois circonscriptions",
15 + "Relations Canada–États-Unis | Les Américains devront s’excuser, selon un ancien ambassadeur",
16 + "Sondage Synopsis-La Presse | Les appuis pour Carney augmentent au Québec",
17 + "En plus du troisième lien | Le maire de Lévis ressuscite l’idée d’un téléphérique vers Québec",
18 + "Comparez les promesses et programmes des partis aux élections québécoises de 2026",
19 + "Charles Milliard, à la rescousse des services publics",
20 + "Le cofondateur de l’ADQ, Jean Allaire, s’est éteint",
21 + "Cofondateur de l’ADQ | Jean Allaire rend l’âme à l’âge de 96 ans"
22 + ],
23 + "cells": [
24 + {
25 + "region": "Montréal",
26 + "age": "18-34 ans",
27 + "gender": "femme",
28 + "lang": "francophone",
29 + "weight": 0.026095679999999996,
30 + "shares": {
31 + "CAQ": 10,
32 + "PLQ": 12,
33 + "PQ": 18,
34 + "QS": 48,
35 + "PCQ": 3,
36 + "AUT": 9
37 + }
38 + },
39 + {
40 + "region": "Montréal",
41 + "age": "18-34 ans",
42 + "gender": "femme",
43 + "lang": "anglophone ou allophone",
44 + "weight": 0.005728319999999999,
45 + "shares": {
46 + "CAQ": 7,
47 + "PLQ": 38,
48 + "PQ": 5,
49 + "QS": 28,
50 + "PCQ": 2,
51 + "AUT": 20
52 + }
53 + },
54 + {
55 + "region": "Montréal",
56 + "age": "18-34 ans",
57 + "gender": "homme",
58 + "lang": "francophone",
59 + "weight": 0.02507232,
60 + "shares": {
61 + "CAQ": 13,
62 + "PLQ": 10,
63 + "PQ": 22,
64 + "QS": 35,
65 + "PCQ": 8,
66 + "AUT": 12
67 + }
68 + },
69 + {
70 + "region": "Montréal",
71 + "age": "18-34 ans",
72 + "gender": "homme",
73 + "lang": "anglophone ou allophone",
74 + "weight": 0.0055036799999999995,
75 + "shares": {
76 + "CAQ": 8,
77 + "PLQ": 34,
78 + "PQ": 6,
79 + "QS": 22,
80 + "PCQ": 5,
81 + "AUT": 25
82 + }
83 + },
84 + {
85 + "region": "Montréal",
86 + "age": "35-54 ans",
87 + "gender": "femme",
88 + "lang": "francophone",
89 + "weight": 0.03312144,
90 + "shares": {
91 + "CAQ": 18,
92 + "PLQ": 15,
93 + "PQ": 25,
94 + "QS": 30,
95 + "PCQ": 5,
96 + "AUT": 7
97 + }
98 + },
99 + {
100 + "region": "Montréal",
101 + "age": "35-54 ans",
102 + "gender": "femme",
103 + "lang": "anglophone ou allophone",
104 + "weight": 0.0072705600000000006,
105 + "shares": {
106 + "CAQ": 8,
107 + "PLQ": 44,
108 + "PQ": 5,
109 + "QS": 20,
110 + "PCQ": 3,
111 + "AUT": 20
112 + }
113 + },
114 + {
115 + "region": "Montréal",
116 + "age": "35-54 ans",
117 + "gender": "homme",
118 + "lang": "francophone",
119 + "weight": 0.03182256,
120 + "shares": {
121 + "CAQ": 20,
122 + "PLQ": 13,
123 + "PQ": 28,
124 + "QS": 22,
125 + "PCQ": 10,
126 + "AUT": 7
127 + }
128 + },
129 + {
130 + "region": "Montréal",
131 + "age": "35-54 ans",
132 + "gender": "homme",
133 + "lang": "anglophone ou allophone",
134 + "weight": 0.00698544,
135 + "shares": {
136 + "CAQ": 9,
137 + "PLQ": 42,
138 + "PQ": 6,
139 + "QS": 16,
140 + "PCQ": 6,
141 + "AUT": 21
142 + }
143 + },
144 + {
145 + "region": "Montréal",
146 + "age": "55 ans et plus",
147 + "gender": "femme",
148 + "lang": "francophone",
149 + "weight": 0.041150879999999994,
150 + "shares": {
151 + "CAQ": 24,
152 + "PLQ": 18,
153 + "PQ": 30,
154 + "QS": 16,
155 + "PCQ": 6,
156 + "AUT": 6
157 + }
158 + },
159 + {
160 + "region": "Montréal",
161 + "age": "55 ans et plus",
162 + "gender": "femme",
163 + "lang": "anglophone ou allophone",
164 + "weight": 0.009033119999999999,
165 + "shares": {
166 + "CAQ": 7,
167 + "PLQ": 55,
168 + "PQ": 5,
169 + "QS": 10,
170 + "PCQ": 4,
171 + "AUT": 19
172 + }
173 + },
174 + {
175 + "region": "Montréal",
176 + "age": "55 ans et plus",
177 + "gender": "homme",
178 + "lang": "francophone",
179 + "weight": 0.039537119999999995,
180 + "shares": {
181 + "CAQ": 26,
182 + "PLQ": 16,
183 + "PQ": 32,
184 + "QS": 12,
185 + "PCQ": 9,
186 + "AUT": 5
187 + }
188 + },
189 + {
190 + "region": "Montréal",
191 + "age": "55 ans et plus",
192 + "gender": "homme",
193 + "lang": "anglophone ou allophone",
194 + "weight": 0.008678879999999998,
195 + "shares": {
196 + "CAQ": 8,
197 + "PLQ": 53,
198 + "PQ": 6,
199 + "QS": 8,
200 + "PCQ": 6,
201 + "AUT": 19
202 + }
203 + },
204 + {
205 + "region": "Montérégie et Rive-Sud",
206 + "age": "18-34 ans",
207 + "gender": "femme",
208 + "lang": "francophone",
209 + "weight": 0.01957176,
210 + "shares": {
211 + "CAQ": 14,
212 + "PLQ": 10,
213 + "PQ": 22,
214 + "QS": 38,
215 + "PCQ": 6,
216 + "AUT": 10
217 + }
218 + },
219 + {
220 + "region": "Montérégie et Rive-Sud",
221 + "age": "18-34 ans",
222 + "gender": "femme",
223 + "lang": "anglophone ou allophone",
224 + "weight": 0.00429624,
225 + "shares": {
226 + "CAQ": 8,
227 + "PLQ": 38,
228 + "PQ": 5,
229 + "QS": 28,
230 + "PCQ": 4,
231 + "AUT": 17
232 + }
233 + },
234 + {
235 + "region": "Montérégie et Rive-Sud",
236 + "age": "18-34 ans",
237 + "gender": "homme",
238 + "lang": "francophone",
239 + "weight": 0.01880424,
240 + "shares": {
241 + "CAQ": 16,
242 + "PLQ": 9,
243 + "PQ": 25,
244 + "QS": 28,
245 + "PCQ": 12,
246 + "AUT": 10
247 + }
248 + },
249 + {
250 + "region": "Montérégie et Rive-Sud",
251 + "age": "18-34 ans",
252 + "gender": "homme",
253 + "lang": "anglophone ou allophone",
254 + "weight": 0.00412776,
255 + "shares": {
256 + "CAQ": 9,
257 + "PLQ": 34,
258 + "PQ": 6,
259 + "QS": 22,
260 + "PCQ": 8,
261 + "AUT": 21
262 + }
263 + },
264 + {
265 + "region": "Montérégie et Rive-Sud",
266 + "age": "35-54 ans",
267 + "gender": "femme",
268 + "lang": "francophone",
269 + "weight": 0.024841079999999998,
270 + "shares": {
271 + "CAQ": 22,
272 + "PLQ": 13,
273 + "PQ": 28,
274 + "QS": 22,
275 + "PCQ": 9,
276 + "AUT": 6
277 + }
278 + },
279 + {
280 + "region": "Montérégie et Rive-Sud",
281 + "age": "35-54 ans",
282 + "gender": "femme",
283 + "lang": "anglophone ou allophone",
284 + "weight": 0.00545292,
285 + "shares": {
286 + "CAQ": 10,
287 + "PLQ": 44,
288 + "PQ": 5,
289 + "QS": 18,
290 + "PCQ": 5,
291 + "AUT": 18
292 + }
293 + },
294 + {
295 + "region": "Montérégie et Rive-Sud",
296 + "age": "35-54 ans",
297 + "gender": "homme",
298 + "lang": "francophone",
299 + "weight": 0.02386692,
300 + "shares": {
301 + "CAQ": 24,
302 + "PLQ": 11,
303 + "PQ": 30,
304 + "QS": 15,
305 + "PCQ": 14,
306 + "AUT": 6
307 + }
308 + },
309 + {
310 + "region": "Montérégie et Rive-Sud",
311 + "age": "35-54 ans",
312 + "gender": "homme",
313 + "lang": "anglophone ou allophone",
314 + "weight": 0.00523908,
315 + "shares": {
316 + "CAQ": 11,
317 + "PLQ": 40,
318 + "PQ": 6,
319 + "QS": 12,
320 + "PCQ": 10,
321 + "AUT": 21
322 + }
323 + },
324 + {
325 + "region": "Montérégie et Rive-Sud",
326 + "age": "55 ans et plus",
327 + "gender": "femme",
328 + "lang": "francophone",
329 + "weight": 0.030863159999999997,
330 + "shares": {
331 + "CAQ": 30,
332 + "PLQ": 16,
333 + "PQ": 28,
334 + "QS": 12,
335 + "PCQ": 9,
336 + "AUT": 5
337 + }
338 + },
339 + {
340 + "region": "Montérégie et Rive-Sud",
341 + "age": "55 ans et plus",
342 + "gender": "femme",
343 + "lang": "anglophone ou allophone",
344 + "weight": 0.006774839999999999,
345 + "shares": {
346 + "CAQ": 10,
347 + "PLQ": 52,
348 + "PQ": 5,
349 + "QS": 8,
350 + "PCQ": 6,
351 + "AUT": 19
352 + }
353 + },
354 + {
355 + "region": "Montérégie et Rive-Sud",
356 + "age": "55 ans et plus",
357 + "gender": "homme",
358 + "lang": "francophone",
359 + "weight": 0.029652839999999993,
360 + "shares": {
361 + "CAQ": 28,
362 + "PLQ": 14,
363 + "PQ": 30,
364 + "QS": 8,
365 + "PCQ": 14,
366 + "AUT": 6
367 + }
368 + },
369 + {
370 + "region": "Montérégie et Rive-Sud",
371 + "age": "55 ans et plus",
372 + "gender": "homme",
373 + "lang": "anglophone ou allophone",
374 + "weight": 0.006509159999999998,
375 + "shares": {
376 + "CAQ": 11,
377 + "PLQ": 48,
378 + "PQ": 6,
379 + "QS": 5,
380 + "PCQ": 11,
381 + "AUT": 19
382 + }
383 + },
384 + {
385 + "region": "Québec et Chaudière-Appalaches",
386 + "age": "18-34 ans",
387 + "gender": "femme",
388 + "lang": "francophone",
389 + "weight": 0.01522248,
390 + "shares": {
391 + "CAQ": 14,
392 + "PLQ": 8,
393 + "PQ": 22,
394 + "QS": 38,
395 + "PCQ": 10,
396 + "AUT": 8
397 + }
398 + },
399 + {
400 + "region": "Québec et Chaudière-Appalaches",
401 + "age": "18-34 ans",
402 + "gender": "femme",
403 + "lang": "anglophone ou allophone",
404 + "weight": 0.00334152,
405 + "shares": {
406 + "CAQ": 10,
407 + "PLQ": 32,
408 + "PQ": 8,
409 + "QS": 28,
410 + "PCQ": 6,
411 + "AUT": 16
412 + }
413 + },
414 + {
415 + "region": "Québec et Chaudière-Appalaches",
416 + "age": "18-34 ans",
417 + "gender": "homme",
418 + "lang": "francophone",
419 + "weight": 0.01462552,
420 + "shares": {
421 + "CAQ": 16,
422 + "PLQ": 7,
423 + "PQ": 24,
424 + "QS": 26,
425 + "PCQ": 18,
426 + "AUT": 9
427 + }
428 + },
429 + {
430 + "region": "Québec et Chaudière-Appalaches",
431 + "age": "18-34 ans",
432 + "gender": "homme",
433 + "lang": "anglophone ou allophone",
434 + "weight": 0.00321048,
435 + "shares": {
436 + "CAQ": 12,
437 + "PLQ": 28,
438 + "PQ": 10,
439 + "QS": 18,
440 + "PCQ": 16,
441 + "AUT": 16
442 + }
443 + },
444 + {
445 + "region": "Québec et Chaudière-Appalaches",
446 + "age": "35-54 ans",
447 + "gender": "femme",
448 + "lang": "francophone",
449 + "weight": 0.019320840000000002,
450 + "shares": {
451 + "CAQ": 22,
452 + "PLQ": 10,
453 + "PQ": 28,
454 + "QS": 22,
455 + "PCQ": 12,
456 + "AUT": 6
457 + }
458 + },
459 + {
460 + "region": "Québec et Chaudière-Appalaches",
461 + "age": "35-54 ans",
462 + "gender": "femme",
463 + "lang": "anglophone ou allophone",
464 + "weight": 0.004241160000000001,
465 + "shares": {
466 + "CAQ": 14,
467 + "PLQ": 36,
468 + "PQ": 10,
469 + "QS": 18,
470 + "PCQ": 8,
471 + "AUT": 14
472 + }
473 + },
474 + {
475 + "region": "Québec et Chaudière-Appalaches",
476 + "age": "35-54 ans",
477 + "gender": "homme",
478 + "lang": "francophone",
479 + "weight": 0.018563160000000002,
480 + "shares": {
481 + "CAQ": 24,
482 + "PLQ": 9,
483 + "PQ": 28,
484 + "QS": 14,
485 + "PCQ": 20,
486 + "AUT": 5
487 + }
488 + },
489 + {
490 + "region": "Québec et Chaudière-Appalaches",
491 + "age": "35-54 ans",
492 + "gender": "homme",
493 + "lang": "anglophone ou allophone",
494 + "weight": 0.004074840000000001,
495 + "shares": {
496 + "CAQ": 16,
497 + "PLQ": 34,
498 + "PQ": 10,
499 + "QS": 12,
500 + "PCQ": 18,
501 + "AUT": 10
502 + }
503 + },
504 + {
505 + "region": "Québec et Chaudière-Appalaches",
506 + "age": "55 ans et plus",
507 + "gender": "femme",
508 + "lang": "francophone",
509 + "weight": 0.02400468,
510 + "shares": {
511 + "CAQ": 28,
512 + "PLQ": 12,
513 + "PQ": 30,
514 + "QS": 12,
515 + "PCQ": 14,
516 + "AUT": 4
517 + }
518 + },
519 + {
520 + "region": "Québec et Chaudière-Appalaches",
521 + "age": "55 ans et plus",
522 + "gender": "femme",
523 + "lang": "anglophone ou allophone",
524 + "weight": 0.00526932,
525 + "shares": {
526 + "CAQ": 16,
527 + "PLQ": 44,
528 + "PQ": 10,
529 + "QS": 8,
530 + "PCQ": 12,
531 + "AUT": 10
532 + }
533 + },
534 + {
535 + "region": "Québec et Chaudière-Appalaches",
536 + "age": "55 ans et plus",
537 + "gender": "homme",
538 + "lang": "francophone",
539 + "weight": 0.02306332,
540 + "shares": {
541 + "CAQ": 28,
542 + "PLQ": 10,
543 + "PQ": 28,
544 + "QS": 8,
545 + "PCQ": 22,
546 + "AUT": 4
547 + }
548 + },
549 + {
550 + "region": "Québec et Chaudière-Appalaches",
551 + "age": "55 ans et plus",
552 + "gender": "homme",
553 + "lang": "anglophone ou allophone",
554 + "weight": 0.005062679999999999,
555 + "shares": {
556 + "CAQ": 18,
557 + "PLQ": 42,
558 + "PQ": 10,
559 + "QS": 6,
560 + "PCQ": 16,
561 + "AUT": 8
562 + }
563 + },
564 + {
565 + "region": "Laval et Laurentides-Lanaudière",
566 + "age": "18-34 ans",
567 + "gender": "femme",
568 + "lang": "francophone",
569 + "weight": 0.017397120000000002,
570 + "shares": {
571 + "CAQ": 14,
572 + "PLQ": 10,
573 + "PQ": 22,
574 + "QS": 38,
575 + "PCQ": 6,
576 + "AUT": 10
577 + }
578 + },
579 + {
580 + "region": "Laval et Laurentides-Lanaudière",
581 + "age": "18-34 ans",
582 + "gender": "femme",
583 + "lang": "anglophone ou allophone",
584 + "weight": 0.00381888,
585 + "shares": {
586 + "CAQ": 8,
587 + "PLQ": 38,
588 + "PQ": 6,
589 + "QS": 28,
590 + "PCQ": 4,
591 + "AUT": 16
592 + }
593 + },
594 + {
595 + "region": "Laval et Laurentides-Lanaudière",
596 + "age": "18-34 ans",
597 + "gender": "homme",
598 + "lang": "francophone",
599 + "weight": 0.01671488,
600 + "shares": {
601 + "CAQ": 16,
602 + "PLQ": 9,
603 + "PQ": 26,
604 + "QS": 28,
605 + "PCQ": 12,
606 + "AUT": 9
607 + }
608 + },
609 + {
610 + "region": "Laval et Laurentides-Lanaudière",
611 + "age": "18-34 ans",
612 + "gender": "homme",
613 + "lang": "anglophone ou allophone",
614 + "weight": 0.0036691200000000005,
615 + "shares": {
616 + "CAQ": 10,
617 + "PLQ": 34,
618 + "PQ": 7,
619 + "QS": 22,
620 + "PCQ": 8,
621 + "AUT": 19
622 + }
623 + },
624 + {
625 + "region": "Laval et Laurentides-Lanaudière",
626 + "age": "35-54 ans",
627 + "gender": "femme",
628 + "lang": "francophone",
629 + "weight": 0.022080960000000004,
630 + "shares": {
631 + "CAQ": 22,
632 + "PLQ": 12,
633 + "PQ": 30,
634 + "QS": 22,
635 + "PCQ": 8,
636 + "AUT": 6
637 + }
638 + },
639 + {
640 + "region": "Laval et Laurentides-Lanaudière",
641 + "age": "35-54 ans",
642 + "gender": "femme",
643 + "lang": "anglophone ou allophone",
644 + "weight": 0.00484704,
645 + "shares": {
646 + "CAQ": 10,
647 + "PLQ": 44,
648 + "PQ": 7,
649 + "QS": 18,
650 + "PCQ": 5,
651 + "AUT": 16
652 + }
653 + },
654 + {
655 + "region": "Laval et Laurentides-Lanaudière",
656 + "age": "35-54 ans",
657 + "gender": "homme",
658 + "lang": "francophone",
659 + "weight": 0.02121504,
660 + "shares": {
661 + "CAQ": 24,
662 + "PLQ": 10,
663 + "PQ": 32,
664 + "QS": 14,
665 + "PCQ": 14,
666 + "AUT": 6
667 + }
668 + },
669 + {
670 + "region": "Laval et Laurentides-Lanaudière",
671 + "age": "35-54 ans",
672 + "gender": "homme",
673 + "lang": "anglophone ou allophone",
674 + "weight": 0.00465696,
675 + "shares": {
676 + "CAQ": 12,
677 + "PLQ": 40,
678 + "PQ": 8,
679 + "QS": 12,
680 + "PCQ": 10,
681 + "AUT": 18
682 + }
683 + },
684 + {
685 + "region": "Laval et Laurentides-Lanaudière",
686 + "age": "55 ans et plus",
687 + "gender": "femme",
688 + "lang": "francophone",
689 + "weight": 0.027433919999999997,
690 + "shares": {
691 + "CAQ": 28,
692 + "PLQ": 14,
693 + "PQ": 34,
694 + "QS": 10,
695 + "PCQ": 9,
696 + "AUT": 5
697 + }
698 + },
699 + {
700 + "region": "Laval et Laurentides-Lanaudière",
701 + "age": "55 ans et plus",
702 + "gender": "femme",
703 + "lang": "anglophone ou allophone",
704 + "weight": 0.00602208,
705 + "shares": {
706 + "CAQ": 10,
707 + "PLQ": 52,
708 + "PQ": 8,
709 + "QS": 8,
710 + "PCQ": 6,
711 + "AUT": 16
712 + }
713 + },
714 + {
715 + "region": "Laval et Laurentides-Lanaudière",
716 + "age": "55 ans et plus",
717 + "gender": "homme",
718 + "lang": "francophone",
719 + "weight": 0.026358079999999992,
720 + "shares": {
721 + "CAQ": 26,
722 + "PLQ": 12,
723 + "PQ": 36,
724 + "QS": 7,
725 + "PCQ": 14,
726 + "AUT": 5
727 + }
728 + },
729 + {
730 + "region": "Laval et Laurentides-Lanaudière",
731 + "age": "55 ans et plus",
732 + "gender": "homme",
733 + "lang": "anglophone ou allophone",
734 + "weight": 0.005785919999999998,
735 + "shares": {
736 + "CAQ": 12,
737 + "PLQ": 48,
738 + "PQ": 9,
739 + "QS": 5,
740 + "PCQ": 10,
741 + "AUT": 16
742 + }
743 + },
744 + {
745 + "region": "Estrie, Centre-du-Québec et Mauricie",
746 + "age": "18-34 ans",
747 + "gender": "femme",
748 + "lang": "francophone",
749 + "weight": 0.013047839999999998,
750 + "shares": {
751 + "CAQ": 14,
752 + "PLQ": 8,
753 + "PQ": 22,
754 + "QS": 42,
755 + "PCQ": 6,
756 + "AUT": 8
757 + }
758 + },
759 + {
760 + "region": "Estrie, Centre-du-Québec et Mauricie",
761 + "age": "18-34 ans",
762 + "gender": "femme",
763 + "lang": "anglophone ou allophone",
764 + "weight": 0.0028641599999999997,
765 + "shares": {
766 + "CAQ": 8,
767 + "PLQ": 32,
768 + "PQ": 10,
769 + "QS": 30,
770 + "PCQ": 5,
771 + "AUT": 15
772 + }
773 + },
774 + {
775 + "region": "Estrie, Centre-du-Québec et Mauricie",
776 + "age": "18-34 ans",
777 + "gender": "homme",
778 + "lang": "francophone",
779 + "weight": 0.01253616,
780 + "shares": {
781 + "CAQ": 16,
782 + "PLQ": 7,
783 + "PQ": 26,
784 + "QS": 28,
785 + "PCQ": 14,
786 + "AUT": 9
787 + }
788 + },
789 + {
790 + "region": "Estrie, Centre-du-Québec et Mauricie",
791 + "age": "18-34 ans",
792 + "gender": "homme",
793 + "lang": "anglophone ou allophone",
794 + "weight": 0.0027518399999999998,
795 + "shares": {
796 + "CAQ": 9,
797 + "PLQ": 28,
798 + "PQ": 12,
799 + "QS": 22,
800 + "PCQ": 10,
801 + "AUT": 19
802 + }
803 + },
804 + {
805 + "region": "Estrie, Centre-du-Québec et Mauricie",
806 + "age": "35-54 ans",
807 + "gender": "femme",
808 + "lang": "francophone",
809 + "weight": 0.01656072,
810 + "shares": {
811 + "CAQ": 22,
812 + "PLQ": 12,
813 + "PQ": 30,
814 + "QS": 22,
815 + "PCQ": 9,
816 + "AUT": 5
817 + }
818 + },
819 + {
820 + "region": "Estrie, Centre-du-Québec et Mauricie",
821 + "age": "35-54 ans",
822 + "gender": "femme",
823 + "lang": "anglophone ou allophone",
824 + "weight": 0.0036352800000000003,
825 + "shares": {
826 + "CAQ": 10,
827 + "PLQ": 38,
828 + "PQ": 12,
829 + "QS": 18,
830 + "PCQ": 7,
831 + "AUT": 15
832 + }
833 + },
834 + {
835 + "region": "Estrie, Centre-du-Québec et Mauricie",
836 + "age": "35-54 ans",
837 + "gender": "homme",
838 + "lang": "francophone",
839 + "weight": 0.01591128,
840 + "shares": {
841 + "CAQ": 24,
842 + "PLQ": 10,
843 + "PQ": 32,
844 + "QS": 14,
845 + "PCQ": 16,
846 + "AUT": 4
847 + }
848 + },
849 + {
850 + "region": "Estrie, Centre-du-Québec et Mauricie",
851 + "age": "35-54 ans",
852 + "gender": "homme",
853 + "lang": "anglophone ou allophone",
854 + "weight": 0.00349272,
855 + "shares": {
856 + "CAQ": 11,
857 + "PLQ": 34,
858 + "PQ": 14,
859 + "QS": 12,
860 + "PCQ": 14,
861 + "AUT": 15
862 + }
863 + },
864 + {
865 + "region": "Estrie, Centre-du-Québec et Mauricie",
866 + "age": "55 ans et plus",
867 + "gender": "femme",
868 + "lang": "francophone",
869 + "weight": 0.020575439999999997,
870 + "shares": {
871 + "CAQ": 28,
872 + "PLQ": 14,
873 + "PQ": 34,
874 + "QS": 10,
875 + "PCQ": 10,
876 + "AUT": 4
877 + }
878 + },
879 + {
880 + "region": "Estrie, Centre-du-Québec et Mauricie",
881 + "age": "55 ans et plus",
882 + "gender": "femme",
883 + "lang": "anglophone ou allophone",
884 + "weight": 0.004516559999999999,
885 + "shares": {
886 + "CAQ": 10,
887 + "PLQ": 44,
888 + "PQ": 14,
889 + "QS": 8,
890 + "PCQ": 8,
891 + "AUT": 16
892 + }
893 + },
894 + {
895 + "region": "Estrie, Centre-du-Québec et Mauricie",
896 + "age": "55 ans et plus",
897 + "gender": "homme",
898 + "lang": "francophone",
899 + "weight": 0.019768559999999998,
900 + "shares": {
901 + "CAQ": 26,
902 + "PLQ": 12,
903 + "PQ": 36,
904 + "QS": 7,
905 + "PCQ": 16,
906 + "AUT": 3
907 + }
908 + },
909 + {
910 + "region": "Estrie, Centre-du-Québec et Mauricie",
911 + "age": "55 ans et plus",
912 + "gender": "homme",
913 + "lang": "anglophone ou allophone",
914 + "weight": 0.004339439999999999,
915 + "shares": {
916 + "CAQ": 11,
917 + "PLQ": 40,
918 + "PQ": 16,
919 + "QS": 6,
920 + "PCQ": 14,
921 + "AUT": 13
922 + }
923 + },
924 + {
925 + "region": "Outaouais et Abitibi",
926 + "age": "18-34 ans",
927 + "gender": "femme",
928 + "lang": "francophone",
929 + "weight": 0.006523919999999999,
930 + "shares": {
931 + "CAQ": 14,
932 + "PLQ": 18,
933 + "PQ": 22,
934 + "QS": 36,
935 + "PCQ": 5,
936 + "AUT": 5
937 + }
938 + },
939 + {
940 + "region": "Outaouais et Abitibi",
941 + "age": "18-34 ans",
942 + "gender": "femme",
943 + "lang": "anglophone ou allophone",
944 + "weight": 0.0014320799999999999,
945 + "shares": {
946 + "CAQ": 8,
947 + "PLQ": 42,
948 + "PQ": 6,
949 + "QS": 30,
950 + "PCQ": 3,
951 + "AUT": 11
952 + }
953 + },
954 + {
955 + "region": "Outaouais et Abitibi",
956 + "age": "18-34 ans",
957 + "gender": "homme",
958 + "lang": "francophone",
959 + "weight": 0.00626808,
960 + "shares": {
961 + "CAQ": 16,
962 + "PLQ": 14,
963 + "PQ": 26,
964 + "QS": 26,
965 + "PCQ": 12,
966 + "AUT": 6
967 + }
968 + },
969 + {
970 + "region": "Outaouais et Abitibi",
971 + "age": "18-34 ans",
972 + "gender": "homme",
973 + "lang": "anglophone ou allophone",
974 + "weight": 0.0013759199999999999,
975 + "shares": {
976 + "CAQ": 9,
977 + "PLQ": 38,
978 + "PQ": 7,
979 + "QS": 22,
980 + "PCQ": 10,
981 + "AUT": 14
982 + }
983 + },
984 + {
985 + "region": "Outaouais et Abitibi",
986 + "age": "35-54 ans",
987 + "gender": "femme",
988 + "lang": "francophone",
989 + "weight": 0.00828036,
990 + "shares": {
991 + "CAQ": 20,
992 + "PLQ": 22,
993 + "PQ": 28,
994 + "QS": 20,
995 + "PCQ": 6,
996 + "AUT": 4
997 + }
998 + },
999 + {
1000 + "region": "Outaouais et Abitibi",
1001 + "age": "35-54 ans",
1002 + "gender": "femme",
1003 + "lang": "anglophone ou allophone",
1004 + "weight": 0.0018176400000000001,
1005 + "shares": {
1006 + "CAQ": 10,
1007 + "PLQ": 48,
1008 + "PQ": 7,
1009 + "QS": 18,
1010 + "PCQ": 4,
1011 + "AUT": 13
1012 + }
1013 + },
1014 + {
1015 + "region": "Outaouais et Abitibi",
1016 + "age": "35-54 ans",
1017 + "gender": "homme",
1018 + "lang": "francophone",
1019 + "weight": 0.00795564,
1020 + "shares": {
1021 + "CAQ": 22,
1022 + "PLQ": 18,
1023 + "PQ": 32,
1024 + "QS": 14,
1025 + "PCQ": 10,
1026 + "AUT": 4
1027 + }
1028 + },
1029 + {
1030 + "region": "Outaouais et Abitibi",
1031 + "age": "35-54 ans",
1032 + "gender": "homme",
1033 + "lang": "anglophone ou allophone",
1034 + "weight": 0.00174636,
1035 + "shares": {
1036 + "CAQ": 11,
1037 + "PLQ": 45,
1038 + "PQ": 8,
1039 + "QS": 12,
1040 + "PCQ": 10,
1041 + "AUT": 14
1042 + }
1043 + },
1044 + {
1045 + "region": "Outaouais et Abitibi",
1046 + "age": "55 ans et plus",
1047 + "gender": "femme",
1048 + "lang": "francophone",
1049 + "weight": 0.010287719999999998,
1050 + "shares": {
1051 + "CAQ": 24,
1052 + "PLQ": 28,
1053 + "PQ": 30,
1054 + "QS": 9,
1055 + "PCQ": 5,
1056 + "AUT": 4
1057 + }
1058 + },
1059 + {
1060 + "region": "Outaouais et Abitibi",
1061 + "age": "55 ans et plus",
1062 + "gender": "femme",
1063 + "lang": "anglophone ou allophone",
1064 + "weight": 0.0022582799999999997,
1065 + "shares": {
1066 + "CAQ": 9,
1067 + "PLQ": 56,
1068 + "PQ": 6,
1069 + "QS": 8,
1070 + "PCQ": 4,
1071 + "AUT": 17
1072 + }
1073 + },
1074 + {
1075 + "region": "Outaouais et Abitibi",
1076 + "age": "55 ans et plus",
1077 + "gender": "homme",
1078 + "lang": "francophone",
1079 + "weight": 0.009884279999999999,
1080 + "shares": {
1081 + "CAQ": 26,
1082 + "PLQ": 24,
1083 + "PQ": 34,
1084 + "QS": 7,
1085 + "PCQ": 7,
1086 + "AUT": 2
1087 + }
1088 + },
1089 + {
1090 + "region": "Outaouais et Abitibi",
1091 + "age": "55 ans et plus",
1092 + "gender": "homme",
1093 + "lang": "anglophone ou allophone",
1094 + "weight": 0.0021697199999999995,
1095 + "shares": {
1096 + "CAQ": 10,
1097 + "PLQ": 54,
1098 + "PQ": 7,
1099 + "QS": 6,
1100 + "PCQ": 6,
1101 + "AUT": 17
1102 + }
1103 + },
1104 + {
1105 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1106 + "age": "18-34 ans",
1107 + "gender": "femme",
1108 + "lang": "francophone",
1109 + "weight": 0.0054366,
1110 + "shares": {
1111 + "CAQ": 18,
1112 + "PLQ": 5,
1113 + "PQ": 28,
1114 + "QS": 38,
1115 + "PCQ": 6,
1116 + "AUT": 5
1117 + }
1118 + },
1119 + {
1120 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1121 + "age": "18-34 ans",
1122 + "gender": "femme",
1123 + "lang": "anglophone ou allophone",
1124 + "weight": 0.0011934,
1125 + "shares": {
1126 + "CAQ": 12,
1127 + "PLQ": 28,
1128 + "PQ": 10,
1129 + "QS": 32,
1130 + "PCQ": 5,
1131 + "AUT": 13
1132 + }
1133 + },
1134 + {
1135 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1136 + "age": "18-34 ans",
1137 + "gender": "homme",
1138 + "lang": "francophone",
1139 + "weight": 0.0052234000000000004,
1140 + "shares": {
1141 + "CAQ": 20,
1142 + "PLQ": 5,
1143 + "PQ": 32,
1144 + "QS": 24,
1145 + "PCQ": 14,
1146 + "AUT": 5
1147 + }
1148 + },
1149 + {
1150 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1151 + "age": "18-34 ans",
1152 + "gender": "homme",
1153 + "lang": "anglophone ou allophone",
1154 + "weight": 0.0011466,
1155 + "shares": {
1156 + "CAQ": 14,
1157 + "PLQ": 25,
1158 + "PQ": 12,
1159 + "QS": 22,
1160 + "PCQ": 15,
1161 + "AUT": 12
1162 + }
1163 + },
1164 + {
1165 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1166 + "age": "35-54 ans",
1167 + "gender": "femme",
1168 + "lang": "francophone",
1169 + "weight": 0.0069003,
1170 + "shares": {
1171 + "CAQ": 26,
1172 + "PLQ": 8,
1173 + "PQ": 35,
1174 + "QS": 20,
1175 + "PCQ": 8,
1176 + "AUT": 3
1177 + }
1178 + },
1179 + {
1180 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1181 + "age": "35-54 ans",
1182 + "gender": "femme",
1183 + "lang": "anglophone ou allophone",
1184 + "weight": 0.0015147000000000001,
1185 + "shares": {
1186 + "CAQ": 18,
1187 + "PLQ": 30,
1188 + "PQ": 14,
1189 + "QS": 22,
1190 + "PCQ": 8,
1191 + "AUT": 8
1192 + }
1193 + },
1194 + {
1195 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1196 + "age": "35-54 ans",
1197 + "gender": "homme",
1198 + "lang": "francophone",
1199 + "weight": 0.0066297,
1200 + "shares": {
1201 + "CAQ": 28,
1202 + "PLQ": 7,
1203 + "PQ": 38,
1204 + "QS": 12,
1205 + "PCQ": 12,
1206 + "AUT": 3
1207 + }
1208 + },
1209 + {
1210 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1211 + "age": "35-54 ans",
1212 + "gender": "homme",
1213 + "lang": "anglophone ou allophone",
1214 + "weight": 0.0014553,
1215 + "shares": {
1216 + "CAQ": 20,
1217 + "PLQ": 28,
1218 + "PQ": 16,
1219 + "QS": 12,
1220 + "PCQ": 14,
1221 + "AUT": 10
1222 + }
1223 + },
1224 + {
1225 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1226 + "age": "55 ans et plus",
1227 + "gender": "femme",
1228 + "lang": "francophone",
1229 + "weight": 0.0085731,
1230 + "shares": {
1231 + "CAQ": 34,
1232 + "PLQ": 10,
1233 + "PQ": 36,
1234 + "QS": 10,
1235 + "PCQ": 7,
1236 + "AUT": 3
1237 + }
1238 + },
1239 + {
1240 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1241 + "age": "55 ans et plus",
1242 + "gender": "femme",
1243 + "lang": "anglophone ou allophone",
1244 + "weight": 0.0018819000000000002,
1245 + "shares": {
1246 + "CAQ": 22,
1247 + "PLQ": 35,
1248 + "PQ": 18,
1249 + "QS": 10,
1250 + "PCQ": 8,
1251 + "AUT": 7
1252 + }
1253 + },
1254 + {
1255 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1256 + "age": "55 ans et plus",
1257 + "gender": "homme",
1258 + "lang": "francophone",
1259 + "weight": 0.0082369,
1260 + "shares": {
1261 + "CAQ": 32,
1262 + "PLQ": 9,
1263 + "PQ": 40,
1264 + "QS": 7,
1265 + "PCQ": 10,
1266 + "AUT": 2
1267 + }
1268 + },
1269 + {
1270 + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord",
1271 + "age": "55 ans et plus",
1272 + "gender": "homme",
1273 + "lang": "anglophone ou allophone",
1274 + "weight": 0.0018081,
1275 + "shares": {
1276 + "CAQ": 24,
1277 + "PLQ": 33,
1278 + "PQ": 20,
1279 + "QS": 8,
1280 + "PCQ": 10,
1281 + "AUT": 5
1282 + }
1283 + },
1284 + {
1285 + "region": "Bas-Saint-Laurent et Gaspésie",
1286 + "age": "18-34 ans",
1287 + "gender": "femme",
1288 + "lang": "francophone",
1289 + "weight": 0.0054366,
1290 + "shares": {
1291 + "CAQ": 14,
1292 + "PLQ": 6,
1293 + "PQ": 28,
1294 + "QS": 38,
1295 + "PCQ": 6,
1296 + "AUT": 8
1297 + }
1298 + },
1299 + {
1300 + "region": "Bas-Saint-Laurent et Gaspésie",
1301 + "age": "18-34 ans",
1302 + "gender": "femme",
1303 + "lang": "anglophone ou allophone",
1304 + "weight": 0.0011934,
1305 + "shares": {
1306 + "CAQ": 10,
1307 + "PLQ": 28,
1308 + "PQ": 12,
1309 + "QS": 32,
1310 + "PCQ": 4,
1311 + "AUT": 14
1312 + }
1313 + },
1314 + {
1315 + "region": "Bas-Saint-Laurent et Gaspésie",
1316 + "age": "18-34 ans",
1317 + "gender": "homme",
1318 + "lang": "francophone",
1319 + "weight": 0.0052234000000000004,
1320 + "shares": {
1321 + "CAQ": 16,
1322 + "PLQ": 5,
1323 + "PQ": 30,
1324 + "QS": 26,
1325 + "PCQ": 14,
1326 + "AUT": 9
1327 + }
1328 + },
1329 + {
1330 + "region": "Bas-Saint-Laurent et Gaspésie",
1331 + "age": "18-34 ans",
1332 + "gender": "homme",
1333 + "lang": "anglophone ou allophone",
1334 + "weight": 0.0011466,
1335 + "shares": {
1336 + "CAQ": 12,
1337 + "PLQ": 24,
1338 + "PQ": 14,
1339 + "QS": 22,
1340 + "PCQ": 12,
1341 + "AUT": 16
1342 + }
1343 + },
1344 + {
1345 + "region": "Bas-Saint-Laurent et Gaspésie",
1346 + "age": "35-54 ans",
1347 + "gender": "femme",
1348 + "lang": "francophone",
1349 + "weight": 0.0069003,
1350 + "shares": {
1351 + "CAQ": 20,
1352 + "PLQ": 8,
1353 + "PQ": 36,
1354 + "QS": 22,
1355 + "PCQ": 8,
1356 + "AUT": 6
1357 + }
1358 + },
1359 + {
1360 + "region": "Bas-Saint-Laurent et Gaspésie",
1361 + "age": "35-54 ans",
1362 + "gender": "femme",
1363 + "lang": "anglophone ou allophone",
1364 + "weight": 0.0015147000000000001,
1365 + "shares": {
1366 + "CAQ": 14,
1367 + "PLQ": 30,
1368 + "PQ": 18,
1369 + "QS": 20,
1370 + "PCQ": 6,
1371 + "AUT": 12
1372 + }
1373 + },
1374 + {
1375 + "region": "Bas-Saint-Laurent et Gaspésie",
1376 + "age": "35-54 ans",
1377 + "gender": "homme",
1378 + "lang": "francophone",
1379 + "weight": 0.0066297,
1380 + "shares": {
1381 + "CAQ": 22,
1382 + "PLQ": 7,
1383 + "PQ": 38,
1384 + "QS": 14,
1385 + "PCQ": 13,
1386 + "AUT": 6
1387 + }
1388 + },
1389 + {
1390 + "region": "Bas-Saint-Laurent et Gaspésie",
1391 + "age": "35-54 ans",
1392 + "gender": "homme",
1393 + "lang": "anglophone ou allophone",
1394 + "weight": 0.0014553,
1395 + "shares": {
1396 + "CAQ": 16,
1397 + "PLQ": 26,
1398 + "PQ": 20,
1399 + "QS": 12,
1400 + "PCQ": 14,
1401 + "AUT": 12
1402 + }
1403 + },
1404 + {
1405 + "region": "Bas-Saint-Laurent et Gaspésie",
1406 + "age": "55 ans et plus",
1407 + "gender": "femme",
1408 + "lang": "francophone",
1409 + "weight": 0.0085731,
1410 + "shares": {
1411 + "CAQ": 26,
1412 + "PLQ": 10,
1413 + "PQ": 42,
1414 + "QS": 10,
1415 + "PCQ": 7,
1416 + "AUT": 5
1417 + }
1418 + },
1419 + {
1420 + "region": "Bas-Saint-Laurent et Gaspésie",
1421 + "age": "55 ans et plus",
1422 + "gender": "femme",
1423 + "lang": "anglophone ou allophone",
1424 + "weight": 0.0018819000000000002,
1425 + "shares": {
1426 + "CAQ": 18,
1427 + "PLQ": 34,
1428 + "PQ": 24,
1429 + "QS": 8,
1430 + "PCQ": 6,
1431 + "AUT": 10
1432 + }
1433 + },
1434 + {
1435 + "region": "Bas-Saint-Laurent et Gaspésie",
1436 + "age": "55 ans et plus",
1437 + "gender": "homme",
1438 + "lang": "francophone",
1439 + "weight": 0.0082369,
1440 + "shares": {
1441 + "CAQ": 28,
1442 + "PLQ": 9,
1443 + "PQ": 42,
1444 + "QS": 6,
1445 + "PCQ": 10,
1446 + "AUT": 5
1447 + }
1448 + },
1449 + {
1450 + "region": "Bas-Saint-Laurent et Gaspésie",
1451 + "age": "55 ans et plus",
1452 + "gender": "homme",
1453 + "lang": "anglophone ou allophone",
1454 + "weight": 0.0018081,
1455 + "shares": {
1456 + "CAQ": 20,
1457 + "PLQ": 32,
1458 + "PQ": 26,
1459 + "QS": 6,
1460 + "PCQ": 8,
1461 + "AUT": 8
1462 + }
1463 + }
1464 + ]
1465 +}
\ No newline at end of file
modified backend/tests/test_beyond.py +30 −0
@@ -109,3 +109,33 @@ def test_market_blend():
109 109 # sans marché : identité
110 110 same = ENS.market_blend(model, None)
111 111 assert same["blended"] == model and same["weight_market"] == 0.0
112 +
113 +
114 +def test_synthetic_poststratify():
115 + from app.modeling.synthetic_poll import poststratify, strata
116 + st = strata()
117 + assert len(st) == 96
118 + assert abs(sum(s["weight"] for s in st) - 1.0) < 1e-9
119 + cells = [{**s, "shares": {"CAQ": 20, "PLQ": 20, "PQ": 30, "QS": 10, "PCQ": 15, "AUT": 5}}
120 + for s in st]
121 + agg = poststratify(cells)
122 + assert abs(sum(agg.values()) - 100.0) < 0.5
123 + assert abs(agg["PQ"] - 30.0) < 0.2
124 + # strates non normalisées → renormalisées avant agrégation
125 + cells[0]["shares"] = {"CAQ": 40, "PLQ": 40, "PQ": 60, "QS": 20, "PCQ": 30, "AUT": 10}
126 + agg2 = poststratify(cells)
127 + assert abs(sum(agg2.values()) - 100.0) < 0.5
128 +
129 +
130 +def test_forecast_drift_multiplier_widens_only():
131 + from datetime import date
132 + from app.modeling.forecast import forecast
133 + from app.modeling.trend import TrendResult
134 + x = np.zeros(5); P = np.eye(5) * 0.005
135 + tr = TrendResult(dates=[date(2026, 8, 1)], share_mean=np.zeros((1, 6)),
136 + share_lo=np.zeros((1, 6)), share_hi=np.zeros((1, 6)),
137 + x=x, P=P, q=1e-4, loglik=0.0, n_polls=10)
138 + f1 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5))
139 + f2 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5), drift_multiplier=1.45)
140 + assert np.allclose(f1.x, f2.x) # moyenne intacte
141 + assert np.trace(f2.P) > np.trace(f1.P) # variance élargie
modified frontend/assets/app.js +2 −2
@@ -78,8 +78,8 @@ function sortParties(seats) {
78 78
79 79 function headerHTML(active) {
80 80 const links = [
81 ["/", "Aperçu"], ["/carte", "Carte"], ["/sondages", "Sondages"],
82 ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],
81 + ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"], ["/carte", "Carte"],
82 + ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],
83 83 ["/intelligence", "Intelligence"], ["/backtest", "Calibration"],
84 84 ["/methodologie", "Méthodologie"],
85 85 ];
added frontend/aujourdhui.html +251 −0
@@ -0,0 +1,251 @@
1 +<!DOCTYPE html>
2 +<!--
3 + QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
4 + Auteur : Simon-Pierre Boucher
5 + Contact : contact@spboucher.ai
6 + https://www.qc-election.com
7 +-->
8 +<html lang="fr">
9 +<head>
10 +<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>
11 +<meta charset="utf-8">
12 +<meta name="viewport" content="width=device-width, initial-scale=1">
13 +<title>Le point du jour — QC Élection Forecast</title>
14 +<meta name="description" content="Le forecast du jour de l'élection québécoise 2026 : probabilités datées, ce qui a changé depuis hier, attention web, nouveaux signaux.">
15 +<link rel="stylesheet" href="/assets/style.css">
16 +<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
17 +<style>
18 + .day-date { font-size: 15px; letter-spacing: .12em; text-transform: uppercase;
19 + color: var(--muted); font-weight: 600; }
20 + .headline { font-size: clamp(24px, 3.4vw, 40px); font-weight: 800; line-height: 1.25;
21 + margin: 8px 0 4px; }
22 + .delta-up { color: var(--good); } .delta-down { color: #e5484d; }
23 + .delta-flat { color: var(--muted); }
24 + .att-bar { height: 10px; border-radius: 6px; display: flex; overflow: hidden;
25 + margin: 8px 0 4px; }
26 + .mini-spark { height: 34px; }
27 + .synth-badge { border: 1px dashed var(--muted); border-radius: 8px; padding: 2px 8px;
28 + font-size: 11px; color: var(--muted); }
29 + .tick { display:flex; gap:10px; align-items:baseline; padding: 7px 0;
30 + border-bottom: 1px solid var(--line, #232838); font-size: 13.5px; }
31 + .tick:last-child { border-bottom: 0; }
32 + .tick .t { color: var(--muted); white-space: nowrap; font-size: 12px; min-width: 86px; }
33 +</style>
34 +</head>
35 +<body>
36 +<header class="site"></header>
37 +<div class="wrap">
38 + <div class="hero">
39 + <div class="day-date" id="day-date">—</div>
40 + <h1 class="headline" id="headline">Chargement du point du jour…</h1>
41 + <p class="sub" id="day-sub"></p>
42 + <p class="disclaimer">Snapshot daté et archivé — chaque affirmation renvoie au run du modèle
43 + (<a href="/methodologie">méthodologie</a> · <a href="/signaux">décomposition des couches</a>).</p>
44 + </div>
45 +
46 + <h2 class="section">Les chiffres du jour</h2>
47 + <div class="grid parties" id="day-cards"><div class="loading">Chargement…</div></div>
48 + <div class="card">
49 + <div class="kv"><span>Probabilité qu'aucun parti n'obtienne la majorité</span><b id="d-nomaj"></b></div>
50 + <div class="kv"><span>Ce qui bouge depuis la veille</span><b id="d-move"></b></div>
51 + </div>
52 +
53 + <div class="grid two">
54 + <div class="card">
55 + <h3>Ce qui a changé depuis <span id="prev-date">hier</span></h3>
56 + <table class="data" id="delta-table"></table>
57 + <p class="chart-note">Δ = dernier run vs dernier run de la journée précédente.
58 + P(1er) = probabilité publiée (ensemble modèle/marchés).</p>
59 + </div>
60 + <div class="card">
61 + <h3>Le journal des dernières 24 h</h3>
62 + <div id="ticker"></div>
63 + </div>
64 + </div>
65 +
66 + <h2 class="section">Attention web <span class="chip badge-aux">Wikipédia — recherche d'information</span></h2>
67 + <p class="section-sub">Pages des partis et des chef·fe·s (fr.wikipédia) : qui les Québécois·es
68 + recherchent-ils ? La littérature montre que l'attention précède souvent le mouvement — le
69 + signal ne déplace jamais le forecast, mais un pic global <b>élargit l'incertitude de campagne</b>.</p>
70 + <div class="card" id="attention-card"><div class="loading">Chargement…</div></div>
71 +
72 + <div class="grid two">
73 + <div class="card">
74 + <h3>Sondage synthétique IA <span class="synth-badge">EXPÉRIMENTAL — poids nul</span></h3>
75 + <p class="section-sub">« Silicon sampling » : 96 strates démographiques du Québec incarnées
76 + par un LLM local ancré dans l'actualité réelle de la campagne, poststratifiées. Ce n'est
77 + <b>pas un sondage</b> (aucune donnée nouvelle) — publié comme objet d'étude, évalué après
78 + le 5 octobre.</p>
79 + <div id="synthetic-body"><div class="loading">Chargement…</div></div>
80 + </div>
81 + <div class="card">
82 + <h3>À surveiller</h3>
83 + <div class="events" id="upcoming"></div>
84 + <h3 style="margin-top:18px">Les couches du forecast aujourd'hui</h3>
85 + <div id="layer-state"></div>
86 + </div>
87 + </div>
88 +</div>
89 +<footer class="site"></footer>
90 +
91 +<script src="/assets/app.js"></script>
92 +<script>
93 +(async () => {
94 + const meta = await initShell("/aujourdhui");
95 + const parties = Object.keys(meta.parties).filter(p => p !== "AUT");
96 + const T = await getJSON(`${API}/today`);
97 +
98 + const dstr = new Date(T.date + "T12:00:00").toLocaleDateString("fr-CA",
99 + { weekday: "long", year: "numeric", month: "long", day: "numeric" });
100 + document.getElementById("day-date").textContent =
101 + `${dstr} · J−${T.days_to_election} avant le scrutin`;
102 +
103 + const order = [...parties].sort((a, b) => T.prob_most[b] - T.prob_most[a]);
104 + const lead = order[0];
105 + document.getElementById("headline").innerHTML =
106 + `Le <span style="color:${partyColor(lead)}">${partyName(lead)}</span> a
107 + ${(T.prob_most[lead] * 100).toFixed(0)} % de chances de remporter le plus de sièges`;
108 + document.getElementById("day-sub").innerHTML =
109 + `Majorité ${lead} : <b>${(T.seats[lead].prob_majority * 100).toFixed(0)} %</b> ·
110 + vote projeté ${T.vote[lead].toFixed(1)} % ·
111 + ${T.seats[lead].mean.toFixed(0)} sièges attendus [${T.seats[lead].p05}–${T.seats[lead].p95}] ·
112 + run #${T.run_id} (${new Date(T.run_at + "Z").toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })})`;
113 +
114 + const arrow = (d, unit, digits = 1) => {
115 + if (d == null) return "";
116 + const a = Math.abs(d) < (unit === "%" ? 0.005 : 0.05) ? "flat" : d > 0 ? "up" : "down";
117 + const sym = a === "flat" ? "→" : a === "up" ? "▲" : "▼";
118 + const txt = unit === "%" ? (Math.abs(d) * 100).toFixed(1) + " pt" : Math.abs(d).toFixed(digits) + unit;
119 + return `<span class="delta-${a}" style="font-size:12px;font-weight:600">${sym} ${txt}</span>`;
120 + };
121 +
122 + /* ---- cartes du jour ---- */
123 + document.getElementById("day-cards").innerHTML = order.map(p => {
124 + const dp = T.delta ? T.delta.prob_most[p] : null;
125 + const dv = T.delta ? T.delta.vote[p] : null;
126 + return `<div class="card party-card">
127 + <div class="bar" style="background:${partyColor(p)}"></div>
128 + <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 36)}</span>
129 + <div class="pname">${p}</div></div>
130 + <div class="bignum">${(T.prob_most[p] * 100).toFixed(0)} <small>% premier</small> ${arrow(dp, "%")}</div>
131 + <div class="kv"><span>Vote</span><b>${T.vote[p].toFixed(1)} % ${arrow(dv, " pp", 2)}</b></div>
132 + <div class="kv"><span>Sièges</span><b>${T.seats[p].mean.toFixed(0)} [${T.seats[p].p05}–${T.seats[p].p95}]</b></div>
133 + </div>`;
134 + }).join("");
135 + document.getElementById("d-nomaj").textContent = fmtPct(T.prob_no_majority);
136 + const movers = T.delta ? order.map(p => [p, T.delta.vote[p]])
137 + .filter(([, d]) => Math.abs(d) >= 0.05).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) : [];
138 + document.getElementById("d-move").innerHTML = movers.length
139 + ? movers.slice(0, 3).map(([p, d]) =>
140 + `<span style="color:${partyColor(p)}">${p}</span> ${d > 0 ? "+" : ""}${d.toFixed(2)} pp`).join(" · ")
141 + : "journée stable — aucun mouvement notable";
142 +
143 + /* ---- table des deltas ---- */
144 + if (T.delta) {
145 + document.getElementById("prev-date").textContent = fmtDate(T.delta.since);
146 + document.getElementById("delta-table").innerHTML =
147 + `<tr><th>Parti</th><th>P(1er)</th><th>Δ</th><th>Vote</th><th>Δ</th><th>Sièges</th><th>Δ</th></tr>` +
148 + order.map(p => `<tr>
149 + <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>
150 + <td>${(T.prob_most[p] * 100).toFixed(1)} %</td><td>${arrow(T.delta.prob_most[p], "%")}</td>
151 + <td>${T.vote[p].toFixed(1)} %</td><td>${arrow(T.delta.vote[p], " pp", 2)}</td>
152 + <td>${T.seats[p].mean.toFixed(0)}</td><td>${arrow(T.delta.seats[p], "", 1)}</td></tr>`).join("");
153 + } else {
154 + document.getElementById("delta-table").innerHTML =
155 + `<tr><td style="color:var(--muted)">Premier jour de publication — comparaison disponible demain.</td></tr>`;
156 + }
157 +
158 + /* ---- journal 24 h ---- */
159 + const ticks = [];
160 + for (const p of T.new_polls_24h) {
161 + const top = Object.entries(p.shares).filter(([k]) => k !== "AUT")
162 + .sort((a, b) => b[1] - a[1]).slice(0, 2)
163 + .map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" · ");
164 + ticks.push([`sondage`, `<b>${p.pollster}</b> intégré (terrain ${fmtDate(p.field_end)}, n=${p.sample_size ?? "?"}) — ${top}`]);
165 + }
166 + for (const s of T.new_signals_24h) {
167 + ticks.push([s.kind === "poll-radar" ? "radar" : s.kind,
168 + `<a href="${s.url}" target="_blank" rel="noopener">${s.title}</a>${s.pollster ? " · " + s.pollster : ""}`]);
169 + }
170 + document.getElementById("ticker").innerHTML = ticks.length
171 + ? ticks.slice(0, 12).map(([k, h]) =>
172 + `<div class="tick"><span class="t"><span class="chip badge-aux">${k}</span></span><span>${h}</span></div>`).join("")
173 + : '<p class="note">Aucun nouveau sondage ni signal dans les dernières 24 h.</p>';
174 +
175 + /* ---- attention web ---- */
176 + try {
177 + const A = await getJSON(`${API}/attention`);
178 + if (!A.available) throw new Error(A.note || "indisponible");
179 + const ap = A.parties;
180 + const ordA = parties.filter(p => ap[p]).sort((a, b) => ap[b].share_7j - ap[a].share_7j);
181 + document.getElementById("attention-card").innerHTML = `
182 + <div class="att-bar">${ordA.map(p =>
183 + `<div style="flex:${ap[p].share_7j};background:${partyColor(p)}" title="${p} ${ap[p].share_7j} %"></div>`).join("")}</div>
184 + <table class="data">
185 + <tr><th>Parti</th><th>Part d'attention 7 j</th><th>vs base 180 j</th><th>Pic (z, 3 j)</th><th>28 derniers jours</th></tr>
186 + ${ordA.map((p, i) => `<tr>
187 + <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>
188 + <td><b>${ap[p].share_7j.toFixed(1)} %</b> <span style="color:var(--muted);font-size:11px">(${ap[p].views_7j.toLocaleString("fr-CA")} vues)</span></td>
189 + <td class="${ap[p].delta_share_pp > 0.5 ? "delta-up" : ap[p].delta_share_pp < -0.5 ? "delta-down" : "delta-flat"}">${ap[p].delta_share_pp > 0 ? "+" : ""}${ap[p].delta_share_pp.toFixed(1)} pp</td>
190 + <td>${ap[p].z_3j >= 2 ? "🔥 " : ""}${ap[p].z_3j.toFixed(1)}</td>
191 + <td><div class="mini-spark" id="spark-${p}"></div></td></tr>`).join("")}
192 + </table>
193 + <p class="note">${A.note} Turbulence détectée : z max ${A.z_max} → variance de campagne ×${A.drift_multiplier}.
194 + Données au ${fmtDate(A.as_of)} · Wikimedia REST API.</p>`;
195 + for (const p of ordA) {
196 + const c = baseChart(document.getElementById("spark-" + p));
197 + c.setOption({ grid: { left: 0, right: 0, top: 2, bottom: 2 },
198 + xAxis: { type: "category", show: false, data: ap[p].sparkline.map((_, i) => i) },
199 + yAxis: { type: "value", show: false },
200 + series: [{ type: "line", data: ap[p].sparkline, symbol: "none",
201 + color: partyColor(p), lineStyle: { width: 1.5 }, areaStyle: { opacity: 0.15 } }] });
202 + }
203 + } catch (e) {
204 + document.getElementById("attention-card").innerHTML =
205 + `<p class="note">Signal d'attention en cours de constitution — première fenêtre de 30 jours requise.</p>`;
206 + }
207 +
208 + /* ---- sondage synthétique ---- */
209 + try {
210 + const S = await getJSON(`${API}/synthetic`);
211 + const ordS = parties.sort((a, b) => (S.shares[b] || 0) - (S.shares[a] || 0));
212 + document.getElementById("synthetic-body").innerHTML = `
213 + <table class="data">
214 + <tr><th>Parti</th><th>Synthétique</th><th>Forecast</th><th>Écart</th></tr>
215 + ${ordS.map(p => {
216 + const c = (S.forecast_comparison || {})[p] || {};
217 + const d = c.synthetique != null && c.forecast != null ? c.synthetique - c.forecast : null;
218 + return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>
219 + <td><b>${S.shares[p] != null ? S.shares[p].toFixed(1) + " %" : "—"}</b></td>
220 + <td>${c.forecast != null ? c.forecast.toFixed(1) + " %" : "—"}</td>
221 + <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "") + d.toFixed(1) + " pp" : "—"}</td></tr>`;
222 + }).join("")}
223 + </table>
224 + <p class="note">${S.note} Généré le ${fmtDate(S.as_of)} · modèle <code>${S.model}</code> ·
225 + ${S.n_strata} strates poststratifiées · ancré dans ${(S.headlines || []).length} manchettes réelles.</p>`;
226 + } catch (e) {
227 + document.getElementById("synthetic-body").innerHTML =
228 + '<p class="note">Aucun sondage synthétique généré pour l\'instant (LLM local requis).</p>';
229 + }
230 +
231 + /* ---- à surveiller + état des couches ---- */
232 + document.getElementById("upcoming").innerHTML = (T.upcoming_events || []).map(e =>
233 + `<div class="event"><span class="d">${fmtDate(e.date)}</span><span>${e.title}</span></div>`).join("")
234 + || '<span style="color:var(--muted)">Aucun événement à venir enregistré.</span>';
235 + const B = T.beyond || {};
236 + const sat = ((B.fundamentals || {}).satisfaction || {});
237 + const wF = (((B.fundamentals || {}).blend || {}).precision_share || 0) * 100;
238 + const att = B.web_attention || {};
239 + const nudgeMax = Math.max(0, ...Object.values((B.media_adjustment || {}).delta_pp || { x: 0 }).map(Math.abs));
240 + const mkt = B.market_ensemble;
241 + document.getElementById("layer-state").innerHTML = [
242 + ["✅ Votes réels", `${B.n_byelections || 0} partielles dans le filtre`],
243 + ["🏛️ Fondamentaux", `satisfaction ${sat.value != null ? sat.value.toFixed(0) + " %" : "—"} (${sat.method === "firecrawl" ? "veille live" : "repli"}) · poids ${wF.toFixed(1)} %`],
244 + ["👁️ Attention web", att.available ? `z max ${att.z_max} → variance ×${att.drift_multiplier}` : "en constitution"],
245 + ["📰 Médias", `nudge ±${nudgeMax.toFixed(2)} pp`],
246 + ["📈 Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"],
247 + ].map(([t, d]) => `<div class="kv"><span>${t}</span><b style="font-weight:500;color:var(--muted)">${d}</b></div>`).join("");
248 +})();
249 +</script>
250 +</body>
251 +</html>
modified frontend/methodologie.html +11 −0
@@ -99,6 +99,17 @@
99 99 renormalisée — les intentions de vote ne sont pas modifiées</td>
100 100 <td>88 % modèle / 12 % marché</td></tr>
101 101 </table>
102 + <p><b>Signaux web avancés (v2.1)</b> — deux signaux issus de la littérature récente
103 + complètent les couches : (a) l'<b>attention Wikipédia</b> (pageviews quotidiens des pages
104 + des partis et des chef·fe·s, API Wikimedia) — la recherche montre qu'elle ajoute du pouvoir
105 + prédictif au-delà des sondages et fondamentaux (Smith &amp; Gustafson, <i>Public Opinion
106 + Quarterly</i> 2017); comme un pic peut être bon ou mauvais pour un parti, le signal n'est
107 + <b>jamais directionnel</b> : un pic global (z ≥ 2) gonfle la variance de dérive du forecast
108 + (multiplicateur ≤ 1,45) sans toucher aucune moyenne; (b) un <b>sondage synthétique LLM</b>
109 + (« silicon sampling », 96 strates démographiques du Québec poststratifiées, LLM local ancré
110 + dans les manchettes réelles de notre veille) — <b>expérimental, poids strictement nul</b> :
111 + un LLM ne collecte aucune donnée nouvelle (critique McKown-Dawson/Silver 2025); l'écart au
112 + forecast est publié comme objet d'étude et sera évalué après le 5 octobre.</p>
102 113 <p>Une <b>veille web continue</b> (Firecrawl) alimente ces couches à chaque cycle :
103 114 radar des nouveaux sondages dans la presse (alerte de provenance, aucune ingestion
104 115 automatique de chiffres), extraction du % de satisfaits envers le gouvernement, et
105 116