SPB Git

spb/qc-election

Public

Frontend v3 « sharp » + ingestion de sondages parfaite (v2.1.1)

Frontend :
- Refonte style.css : header compact blur, drawer hamburger mobile (verrou, backdrop,
  Escape), safe-areas, anti-zoom iOS 16px, cartes dégradées + hover lift, focus rings,
  skeleton shimmer, clamp typographique, prefers-reduced-motion
- Charts : thème Inter, grille recessive, tooltips arrondis/blur, légendes cercle,
  dégradés sous courbe (areaGrad), traits lumineux (lineGlow), pastilles endLabel,
  barres à coins arrondis, ResizeObserver
- Palette des partis validée (dataviz : CVD ΔE≥20, contraste ≥3:1, 2 surfaces)

Sondages (« ça doit être parfait ») :
- Parseur Wikipédia par SECTION (« Opinion polls »/« Campaign period » acceptées,
  ventilations langue/région et projections exclues) — plus de seuil de 20 rangées :
  la table de campagne entre dès sa 1re rangée
- Mode de collecte par maison (design effects réels), vraie plage de terrain,
  indécis parsés
- Upsert par (maison, fin de terrain) : n/MoE/chiffres révisés mis à jour, jamais
  dupliqués; purge des doublons hérités
- Boucle de garantie radar→base (statut intégré/en attente), pipeline 3 h en campagne

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 30, 2026) parent 0f2a451 Browse files

12 changed files +1,856 −1,522

modified METHODOLOGY.md +11 −2
@@ -27,8 +27,17 @@ Ce document est la référence auditable du système. La version grand public es
27 Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée. 27 Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée.
28 Les HTML bruts sont archivés dans `backend/data/raw/`. 28 Les HTML bruts sont archivés dans `backend/data/raw/`.
29 29
30−Dédoublonnage des sondages : clé (maison, fin de terrain, taille d'échantillon[, parti]); 30+Dédoublonnage des sondages : clé (maison, fin de terrain); un même sondage publié à
31−un même sondage publié à plusieurs endroits n'est compté qu'une fois. 31+plusieurs endroits n'est compté qu'une fois, et une fiche déjà en base est **mise à
32+jour** (taille, MoE, chiffres révisés) plutôt que dupliquée quand Wikipédia la complète.
33+Les tables de sondages sont sélectionnées par leur **section** de page (« Opinion
34+polls », « Campaign period »…) — la table de campagne est captée dès sa première
35+rangée; les ventilations (langue, région) et projections sont exclues. Le mode de
36+collecte est attribué par maison (Léger web, Mainstreet/Pallas IVR, SEGMA téléphone…)
37+pour des design effects réels; la plage de terrain complète est parsée. En période de
38+campagne (≤ 45 j), le pipeline passe de 6 h à **3 h**. Boucle de garantie : chaque
39+sondage détecté par le radar de veille doit se retrouver en base (statut « intégré »),
40+sinon l'alerte reste visible dans l'admin et le point du jour.
32 41
33 ## 2. Pondération des sondages 42 ## 2. Pondération des sondages
34 43
modified backend/app/ingest/firecrawl_watch.py +31 −0
@@ -264,6 +264,36 @@ def sweep_news(db: Session) -> dict:
264 return {"documents": added, "scores": scored} 264 return {"documents": added, "scores": scored}
265 265
266 266
267+def reconcile_radar(db: Session) -> dict:
268+ """Boucle de garantie : chaque sondage détecté par le radar doit finir dans
269+ la base. Une détection avec maison connue est marquée « intégré » quand un
270+ sondage de cette maison a été ajouté/actualisé autour de la détection;
271+ sinon elle reste « nouveau » = alerte visible (admin + point du jour)."""
272+ from datetime import date as _date
273+ cutoff = datetime.now(timezone.utc) - timedelta(days=14)
274+ pending = (db.query(Mo.WebSignal)
275+ .filter(Mo.WebSignal.kind == "poll-radar",
276+ Mo.WebSignal.status == "nouveau",
277+ Mo.WebSignal.detected_at >= cutoff,
278+ Mo.WebSignal.pollster.isnot(None)).all())
279+ integrated, waiting = 0, 0
280+ for sig in pending:
281+ d0 = sig.detected_at.date()
282+ match = (db.query(Mo.Poll).join(Mo.Pollster)
283+ .filter(Mo.Pollster.name == sig.pollster,
284+ Mo.Poll.field_end >= d0 - timedelta(days=10),
285+ Mo.Poll.field_end <= d0 + timedelta(days=3)).first())
286+ if match:
287+ sig.status = "intégré"
288+ sig.extra = {**(sig.extra or {}), "poll_id": match.id,
289+ "field_end": match.field_end.isoformat()}
290+ integrated += 1
291+ else:
292+ waiting += 1
293+ db.commit()
294+ return {"intégrés": integrated, "en_attente": waiting}
295+
296+
267 # --------------------------------------------------------------------------- 297 # ---------------------------------------------------------------------------
268 def run_watch(db: Session) -> dict: 298 def run_watch(db: Session) -> dict:
269 """Cycle complet de veille — chaque mission est isolée (échec toléré).""" 299 """Cycle complet de veille — chaque mission est isolée (échec toléré)."""
@@ -271,6 +301,7 @@ def run_watch(db: Session) -> dict:
271 return {"skipped": "Firecrawl désactivé ou clé absente"} 301 return {"skipped": "Firecrawl désactivé ou clé absente"}
272 report: dict = {} 302 report: dict = {}
273 for name, fn in [("radar_sondages", radar_polls), 303 for name, fn in [("radar_sondages", radar_polls),
304+ ("réconciliation", reconcile_radar),
274 ("satisfaction", fetch_gov_satisfaction), 305 ("satisfaction", fetch_gov_satisfaction),
275 ("presse_elargie", sweep_news)]: 306 ("presse_elargie", sweep_news)]:
276 try: 307 try:
modified backend/app/ingest/wikipedia.py +79 −9
@@ -88,6 +88,14 @@ REGION_RIDINGS = {
88 "Abitibi-Est", "Abitibi-Ouest", "Rouyn-Noranda–Témiscamingue", "Ungava"], 88 "Abitibi-Est", "Abitibi-Ouest", "Rouyn-Noranda–Témiscamingue", "Ungava"],
89 } 89 }
90 90
91+# Mode de collecte habituel par maison (design effects §2 de la méthodologie).
92+HOUSE_MODES = {
93+ "Léger": "web", "Mainstreet": "ivr", "Pallas Data": "ivr", "Synopsis": "web",
94+ "SEGMA": "phone", "Research Co.": "web", "Angus Reid": "web", "EKOS": "ivr",
95+ "Liaison Strategies": "ivr", "Innovative Research": "web", "CROP": "web",
96+ "Forum": "ivr", "Abacus Data": "web", "Ipsos": "web",
97+}
98+
91 POLLSTER_ALIASES = { 99 POLLSTER_ALIASES = {
92 "leger": "Léger", "léger": "Léger", "leger marketing": "Léger", 100 "leger": "Léger", "léger": "Léger", "leger marketing": "Léger",
93 "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet", 101 "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet",
@@ -170,6 +178,49 @@ def _parse_date(x, default_year: int | None = None) -> date | None:
170 return None 178 return None
171 179
172 180
181+def _parse_date_range(x, default_year: int | None = None) -> tuple[date | None, date | None]:
182+ """Plage de terrain « Sept. 30 – Oct. 2, 2026 » → (début, fin).
183+ Sans plage : (None, fin)."""
184+ from dateutil import parser as dp
185+ s = re.sub(r"\[.*?\]", "", str(x)).strip()
186+ end = _parse_date(x, default_year)
187+ parts = re.split(r"[–—]|-(?=\s*[A-Z])", s)
188+ if end is None or len(parts) < 2:
189+ return None, end
190+ lead = parts[0].strip().rstrip(",")
191+ try:
192+ # « September 30 » hérite de l'année/du mois de la fin si absents
193+ start = dp.parse(lead, default=datetime(end.year, end.month, end.day)).date()
194+ if start <= end and (end - start).days <= 45:
195+ return start, end
196+ except (ValueError, OverflowError):
197+ pass
198+ return None, end
199+
200+
201+# Sections de la page dont les tables sont des sondages NATIONAUX; on exclut
202+# explicitement les ventilations (langue, région, âge) et les projections.
203+SECTION_ACCEPT = re.compile(r"opinion poll|campaign period|pre-?campaign", re.I)
204+SECTION_REJECT = re.compile(
205+ r"language|region|montreal|quebec city|age|gender|francophone|anglophone|"
206+ r"projection|leadership|approval|preferred premier|by-?election", re.I)
207+
208+
209+def iter_section_tables(html: str):
210+ """Itère (en-tête de section, HTML de la table) pour chaque wikitable —
211+ le contexte de section permet d'accepter la table nationale et la table de
212+ campagne (même à 1 rangée) tout en rejetant les ventilations régionales."""
213+ heads = [(m.start(), re.sub(r"<[^>]+>", "", m.group(2)).strip())
214+ for m in re.finditer(r"<h([2-5])[^>]*>(.*?)</h\1>", html, re.S)]
215+ section = "?"
216+ hi = 0
217+ for m in re.finditer(r'<table class="wikitable.*?</table>', html, re.S):
218+ while hi < len(heads) and heads[hi][0] < m.start():
219+ section = heads[hi][1]
220+ hi += 1
221+ yield section, m.group(0)
222+
223+
173 def _flat_cols(df: pd.DataFrame) -> list[str]: 224 def _flat_cols(df: pd.DataFrame) -> list[str]:
174 out = [] 225 out = []
175 for c in df.columns: 226 for c in df.columns:
@@ -221,14 +272,19 @@ def parse_poll_table(df: pd.DataFrame, election_year: int, source_url: str) -> l
221 continue 272 continue
222 n = _num(r.get(cols.get("sample size", cols.get("sample", "")))) 273 n = _num(r.get(cols.get("sample size", cols.get("sample", ""))))
223 moe = _num(r.get(cols.get("moe", ""))) 274 moe = _num(r.get(cols.get("moe", "")))
275+ und_col = next((cols[k] for k in cols if "undecided" in k or "und." in k), None)
276+ undecided = _num(r.get(und_col)) if und_col else None
277+ start, _ = _parse_date_range(r.get(date_col), election_year)
278+ house = norm_pollster(org)
224 rows.append({ 279 rows.append({
225− "pollster": norm_pollster(org), 280+ "pollster": house,
226 "field_end": end.isoformat(), 281 "field_end": end.isoformat(),
227− "field_start": (end - timedelta(days=3)).isoformat(), 282+ "field_start": (start or (end - timedelta(days=3))).isoformat(),
228 "sample_size": int(n) if n else None, 283 "sample_size": int(n) if n else None,
229 "moe": abs(moe) if moe else None, 284 "moe": abs(moe) if moe else None,
285+ "undecided": undecided,
230 "population": "adults", 286 "population": "adults",
231− "mode": "unknown", 287+ "mode": HOUSE_MODES.get(house, "unknown"),
232 "region": "QC", 288 "region": "QC",
233 "source_name": "Wikipédia (agrégation)", 289 "source_name": "Wikipédia (agrégation)",
234 "source_url": source_url, 290 "source_url": source_url,
@@ -238,12 +294,26 @@ def parse_poll_table(df: pd.DataFrame, election_year: int, source_url: str) -> l
238 294
239 295
240 def parse_polls_2026(html: str) -> list[dict]: 296 def parse_polls_2026(html: str) -> list[dict]:
241− tables = pd.read_html(StringIO(html)) 297+ """Tables de sondages NATIONAUX de la page 2026, sélectionnées par leur
242− out = [] 298+ section (« Opinion polls », « Campaign period »…) — aucune contrainte de
243− for t in tables: 299+ taille : la table de campagne est captée dès sa première rangée. Les
244− cols = " ".join(str(c).lower() for c in t.columns) 300+ ventilations (langue, région, projections) sont exclues par section."""
245− if "polling organisation" in cols and "caq" in cols and len(t) > 20: 301+ out, seen = [], set()
246− out.extend(parse_poll_table(t, 2026, URL_2026)) 302+ for section, thtml in iter_section_tables(html):
303+ if not SECTION_ACCEPT.search(section) or SECTION_REJECT.search(section):
304+ continue
305+ try:
306+ tables = pd.read_html(StringIO(thtml))
307+ except ValueError:
308+ continue
309+ for t in tables:
310+ cols = " ".join(str(c).lower() for c in t.columns)
311+ if ("polling organisation" in cols or "firm" in cols) and "caq" in cols:
312+ for row in parse_poll_table(t, 2026, URL_2026):
313+ key = (row["pollster"], row["field_end"], row["sample_size"])
314+ if key not in seen: # une table peut apparaître deux fois
315+ seen.add(key)
316+ out.append(row)
247 return out 317 return out
248 318
249 319
modified backend/app/main.py +7 −2
@@ -46,7 +46,10 @@ async def rate_limiter(request: Request, call_next):
46 46
47 47
48 async def _scheduler(): 48 async def _scheduler():
49− """Rafraîchissement automatique du pipeline (collecte + modèle + simulation).""" 49+ """Rafraîchissement automatique du pipeline (collecte + modèle + simulation).
50+ Cadence resserrée à 3 h en période de campagne (≤ 45 jours du scrutin) :
51+ les sondages tombent chaque jour, ils doivent entrer sans délai."""
52+ from datetime import date as _date
50 from .pipeline import run_pipeline 53 from .pipeline import run_pipeline
51 await asyncio.sleep(60) 54 await asyncio.sleep(60)
52 while True: 55 while True:
@@ -55,7 +58,9 @@ async def _scheduler():
55 log.info(f"pipeline: {report}") 58 log.info(f"pipeline: {report}")
56 except Exception as e: # le service continue même si un cycle échoue 59 except Exception as e: # le service continue même si un cycle échoue
57 log.error(f"pipeline en échec: {e}") 60 log.error(f"pipeline en échec: {e}")
58− await asyncio.sleep(settings.refresh_interval_hours * 3600) 61+ days_left = (settings.election_date - _date.today()).days
62+ hours = 3.0 if 0 <= days_left <= 45 else settings.refresh_interval_hours
63+ await asyncio.sleep(hours * 3600)
59 64
60 65
61 @asynccontextmanager 66 @asynccontextmanager
modified backend/app/seed.py +54 −11
@@ -93,8 +93,28 @@ def seed_all(db: Session | None = None) -> dict:
93 party=p, defaults=dict(pct=float(r[p]))) 93 party=p, defaults=dict(pct=float(r[p])))
94 obj.pct = float(r[p]) 94 obj.pct = float(r[p])
95 95
96+ # Purge des doublons hérités de l'ancienne clé (maison, fin, taille) :
97+ # un même sondage réingéré quand Wikipédia complétait sa taille.
98+ n_dupes = 0
99+ seen_polls: dict[tuple, Mo.Poll] = {}
100+ for poll in db.query(Mo.Poll).order_by(Mo.Poll.id.asc()).all():
101+ key = (poll.pollster_id, poll.field_end, poll.election_id)
102+ kept = seen_polls.get(key)
103+ if kept is None:
104+ seen_polls[key] = poll
105+ else: # garder la version la plus complète (taille connue, + de partis)
106+ a = (kept.sample_size is not None, len(kept.results), -kept.id)
107+ b = (poll.sample_size is not None, len(poll.results), -poll.id)
108+ if b > a:
109+ seen_polls[key], poll = poll, kept
110+ db.delete(poll)
111+ n_dupes += 1
112+ if n_dupes:
113+ db.flush()
114+ stats["dupes_purgés"] = n_dupes
115+
96 # Sondages 116 # Sondages
97− n_polls = 0 117+ n_polls, n_updated = 0, 0
98 for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)): 118 for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)):
99 f = DATA_DIR / fname 119 f = DATA_DIR / fname
100 if not f.exists(): 120 if not f.exists():
@@ -107,30 +127,53 @@ def seed_all(db: Session | None = None) -> dict:
107 pol, _ = get_or_create(db, Mo.Pollster, name=pollster) 127 pol, _ = get_or_create(db, Mo.Pollster, name=pollster)
108 first = grp.iloc[0] 128 first = grp.iloc[0]
109 ss = None if pd.isna(first["sample_size"]) else int(first["sample_size"]) 129 ss = None if pd.isna(first["sample_size"]) else int(first["sample_size"])
130+ # correspondance par (maison, fin de terrain) SANS la taille :
131+ # Wikipédia complète souvent n/MoE après coup — même sondage.
110 poll = (db.query(Mo.Poll) 132 poll = (db.query(Mo.Poll)
111 .filter_by(pollster_id=pol.id, 133 .filter_by(pollster_id=pol.id,
112− field_end=date.fromisoformat(fe), sample_size=ss).first()) 134+ field_end=date.fromisoformat(fe)).first())
113 if poll is None: 135 if poll is None:
114 poll = Mo.Poll( 136 poll = Mo.Poll(
115 election_id=el.id, pollster_id=pol.id, 137 election_id=el.id, pollster_id=pol.id,
116 field_start=date.fromisoformat(first["field_start"]), 138 field_start=date.fromisoformat(first["field_start"]),
117 field_end=date.fromisoformat(fe), sample_size=ss, 139 field_end=date.fromisoformat(fe), sample_size=ss,
118− moe=None if pd.isna(first["moe"]) else float(first["moe"]), 140+ population=first["population"],
119− population=first["population"], mode=first["mode"],
120 region=first["region"], source_name=first["source_name"], 141 region=first["region"], source_name=first["source_name"],
121− source_url=first["source_url"],
122 accessed_at=datetime.fromisoformat(first["accessed_at"])) 142 accessed_at=datetime.fromisoformat(first["accessed_at"]))
123 db.add(poll); db.flush() 143 db.add(poll); db.flush()
124 n_polls += 1 144 n_polls += 1
125− existing = {pr.party for pr in poll.results} 145+ # métadonnées raffinées à chaque passage (n, MoE, mode, indécis, URL)
146+ if ss is not None:
147+ poll.sample_size = ss
148+ if not pd.isna(first["moe"]):
149+ poll.moe = float(first["moe"])
150+ if "undecided" in first and not pd.isna(first.get("undecided")):
151+ poll.undecided = float(first["undecided"])
152+ if first["mode"] and str(first["mode"]) != "nan" and (
153+ poll.mode in (None, "unknown") or poll.mode != first["mode"]):
154+ poll.mode = first["mode"]
155+ if not pd.isna(first["source_url"]):
156+ poll.source_url = first["source_url"]
157+ # résultats : création OU correction si Wikipédia a révisé un chiffre
126 total = grp["raw_value"].sum() 158 total = grp["raw_value"].sum()
159+ by_party = {pr.party: pr for pr in poll.results}
160+ changed = False
127 for _, row in grp.iterrows(): 161 for _, row in grp.iterrows():
128− if row["party"] in existing: 162+ raw = float(row["raw_value"])
129− continue 163+ normalized = round(raw * 100.0 / total, 3)
130− poll.results.append(Mo.PollResult( 164+ pr = by_party.get(row["party"])
131− party=row["party"], raw_value=float(row["raw_value"]), 165+ if pr is None:
132− normalized_value=round(float(row["raw_value"]) * 100.0 / total, 3))) 166+ poll.results.append(Mo.PollResult(
167+ party=row["party"], raw_value=raw,
168+ normalized_value=normalized))
169+ elif abs(pr.raw_value - raw) > 1e-9:
170+ pr.raw_value = raw
171+ pr.normalized_value = normalized
172+ changed = True
173+ if changed:
174+ n_updated += 1
133 stats["new_polls"] = n_polls 175 stats["new_polls"] = n_polls
176+ stats["updated_polls"] = n_updated
134 177
135 # Événements clés 178 # Événements clés
136 for d, title, kind, imp in KEY_EVENTS: 179 for d, title, kind, imp in KEY_EVENTS:
modified backend/data/polls_2022.csv +911 −911
Large diff (1,822 lines) is not loaded by default to keep the page fast.Load diff
modified backend/data/polls_2026.csv +413 −413
Large diff (826 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/assets/app.js +93 −27
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/assets/style.css +239 −136
Large diff (493 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/aujourdhui.html +3 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/index.html +10 −5
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/signaux.html +5 −4
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme