SPB Git

spb/qc-election

Public

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 (Aug 30, 2026) parent 03c9ead Browse files

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 chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie 101 chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie
102 au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`). 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 **Décomposition** : à chaque run, le forecast est reconstruit couche par couche 124 **Décomposition** : à chaque run, le forecast est reconstruit couche par couche
105 (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par 125 (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par
106 variante; les Δ de vote et de probabilité de chaque couche sont publiés. 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 return {"id": row.id, "status": "enregistré"} 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 @router.get("/logs", dependencies=[Depends(require_admin)]) 184 @router.get("/logs", dependencies=[Depends(require_admin)])
164 def logs(db: Session = Depends(get_db), limit: int = 100): 185 def logs(db: Session = Depends(get_db), limit: int = 100):
165 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all()) 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 for s in rows]} 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 @router.get("/polls") 317 @router.get("/polls")
223 def polls(db: Session = Depends(get_db), 318 def polls(db: Session = Depends(get_db),
224 date_from: date | None = None, date_to: date | None = None, 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 class Settings(BaseSettings): 36 class Settings(BaseSettings):
37 # --- Général --- 37 # --- Général ---
38 app_name: str = "QC Élection Forecast" 38 app_name: str = "QC Élection Forecast"
39− model_version: str = "2.0.0" 39+ model_version: str = "2.1.0"
40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}" 40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"
41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me") 41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")
42 port: int = 8140 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 return {"status": "ok", "app": settings.app_name, "version": settings.model_version} 78 return {"status": "ok", "app": settings.app_name, "version": settings.model_version}
79 79
80 80
81−PAGES = ["index", "carte", "sondages", "signaux", "simulateur", "intelligence", 81+PAGES = ["index", "aujourdhui", "carte", "sondages", "signaux", "simulateur",
82− "methodologie", "backtest", "admin"] 82+ "intelligence", "methodologie", "backtest", "admin"]
83 for page in PAGES: 83 for page in PAGES:
84 path = "/" if page == "index" else f"/{page}" 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 def forecast(trend: TrendResult, as_of: date, election_day: date, 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 rng = rng or np.random.default_rng(2) 65 rng = rng or np.random.default_rng(2)
63 days = max(0, (election_day - as_of).days) 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 # + erreur systémique de sondage (corrélée entre partis via la composition) : 68 # + erreur systémique de sondage (corrélée entre partis via la composition) :
66 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp. 69 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp.
67 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M) 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 if trend is None: 116 if trend is None:
117 raise RuntimeError("Pas assez de sondages pour ajuster le modèle") 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 now_d = nc(trend, as_of) 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 # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) --- 133 # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) ---
123 result_2022 = (db.query(Mo.Election) 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 s = SIM.run_simulation(inp, n_sims=8000, 162 s = SIM.run_simulation(inp, n_sims=8000,
152 rng=np.random.default_rng(seed)) 163 rng=np.random.default_rng(seed))
153 return {p: s.seats[p]["prob_most"] for p in settings.parties} 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 variants = [ 166 variants = [
156 ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P), 167 ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P),
157 ("partielles", "+ votes réels des partielles", fc_b.x, fc_b.P), 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 beyond = { 219 beyond = {
209 "decomposition": decomposition, 220 "decomposition": decomposition,
221+ "web_attention": {k: v for k, v in attention.items() if k != "sparkline_dates"},
210 "fundamentals": fund_diag, 222 "fundamentals": fund_diag,
211 "byelections_used": [{"pollster": b["pollster"], 223 "byelections_used": [{"pollster": b["pollster"],
212 "field_end": b["field_end"].isoformat(), 224 "field_end": b["field_end"].isoformat(),
@@ -293,6 +305,21 @@ def run_pipeline(full_refresh: bool = True) -> dict:
293 except Exception as e: 305 except Exception as e:
294 report["veille"] = f"échec: {e}" 306 report["veille"] = f"échec: {e}"
295 _log(db, "veille-firecrawl", "error", traceback.format_exc()) 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 try: 323 try:
297 run = run_forecast(db) 324 run = run_forecast(db)
298 report["forecast_run_id"] = run.id 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 "88/12 avec Polymarket sur P(plus de sièges), veille web continue " 149 "88/12 avec Polymarket sur P(plus de sièges), veille web continue "
150 "Firecrawl (radar sondages + satisfaction + presse élargie), " 150 "Firecrawl (radar sondages + satisfaction + presse élargie), "
151 "décomposition du forecast par couche, backtest comparatif v2."))) 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 # Sources 161 # Sources
153 for name, url, kind in [ 162 for name, url, kind in [
154 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"), 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 # sans marché : identité 109 # sans marché : identité
110 same = ENS.market_blend(model, None) 110 same = ENS.market_blend(model, None)
111 assert same["blended"] == model and same["weight_market"] == 0.0 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 function headerHTML(active) { 79 function headerHTML(active) {
80 const links = [ 80 const links = [
81− ["/", "Aperçu"], ["/carte", "Carte"], ["/sondages", "Sondages"], 81+ ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"], ["/carte", "Carte"],
82− ["/signaux", "Signaux"], ["/simulateur", "Simulateur"], 82+ ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],
83 ["/intelligence", "Intelligence"], ["/backtest", "Calibration"], 83 ["/intelligence", "Intelligence"], ["/backtest", "Calibration"],
84 ["/methodologie", "Méthodologie"], 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 renormalisée — les intentions de vote ne sont pas modifiées</td> 99 renormalisée — les intentions de vote ne sont pas modifiées</td>
100 <td>88 % modèle / 12 % marché</td></tr> 100 <td>88 % modèle / 12 % marché</td></tr>
101 </table> 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 <p>Une <b>veille web continue</b> (Firecrawl) alimente ces couches à chaque cycle : 113 <p>Une <b>veille web continue</b> (Firecrawl) alimente ces couches à chaque cycle :
103 radar des nouveaux sondages dans la presse (alerte de provenance, aucune ingestion 114 radar des nouveaux sondages dans la presse (alerte de provenance, aucune ingestion
104 automatique de chiffres), extraction du % de satisfaits envers le gouvernement, et 115 automatique de chiffres), extraction du % de satisfaits envers le gouvernement, et
105 116
Menu

Theme