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>
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 | 27 | Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée. |
| 28 | 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]); | |
| 31 | −un même sondage publié à plusieurs endroits n'est compté qu'une fois. | |
| 30 | +Dédoublonnage des sondages : clé (maison, fin de terrain); un même sondage publié à | |
| 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 | 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 | 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 | 298 | def run_watch(db: Session) -> dict: |
| 269 | 299 | """Cycle complet de veille — chaque mission est isolée (échec toléré).""" |
@@ -271,6 +301,7 @@ def run_watch(db: Session) -> dict: | ||
| 271 | 301 | return {"skipped": "Firecrawl désactivé ou clé absente"} |
| 272 | 302 | report: dict = {} |
| 273 | 303 | for name, fn in [("radar_sondages", radar_polls), |
| 304 | + ("réconciliation", reconcile_radar), | |
| 274 | 305 | ("satisfaction", fetch_gov_satisfaction), |
| 275 | 306 | ("presse_elargie", sweep_news)]: |
| 276 | 307 | try: |
modified
backend/app/ingest/wikipedia.py
+79 −9
@@ -88,6 +88,14 @@ REGION_RIDINGS = { | ||
| 88 | 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 | 99 | POLLSTER_ALIASES = { |
| 92 | 100 | "leger": "Léger", "léger": "Léger", "leger marketing": "Léger", |
| 93 | 101 | "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet", |
@@ -170,6 +178,49 @@ def _parse_date(x, default_year: int | None = None) -> date | None: | ||
| 170 | 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 | 224 | def _flat_cols(df: pd.DataFrame) -> list[str]: |
| 174 | 225 | out = [] |
| 175 | 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 | 272 | continue |
| 222 | 273 | n = _num(r.get(cols.get("sample size", cols.get("sample", "")))) |
| 223 | 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 | 279 | rows.append({ |
| 225 | − "pollster": norm_pollster(org), | |
| 280 | + "pollster": house, | |
| 226 | 281 | "field_end": end.isoformat(), |
| 227 | − "field_start": (end - timedelta(days=3)).isoformat(), | |
| 282 | + "field_start": (start or (end - timedelta(days=3))).isoformat(), | |
| 228 | 283 | "sample_size": int(n) if n else None, |
| 229 | 284 | "moe": abs(moe) if moe else None, |
| 285 | + "undecided": undecided, | |
| 230 | 286 | "population": "adults", |
| 231 | − "mode": "unknown", | |
| 287 | + "mode": HOUSE_MODES.get(house, "unknown"), | |
| 232 | 288 | "region": "QC", |
| 233 | 289 | "source_name": "Wikipédia (agrégation)", |
| 234 | 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 | 296 | def parse_polls_2026(html: str) -> list[dict]: |
| 241 | − tables = pd.read_html(StringIO(html)) | |
| 242 | − out = [] | |
| 243 | − for t in tables: | |
| 244 | − cols = " ".join(str(c).lower() for c in t.columns) | |
| 245 | − if "polling organisation" in cols and "caq" in cols and len(t) > 20: | |
| 246 | − out.extend(parse_poll_table(t, 2026, URL_2026)) | |
| 297 | + """Tables de sondages NATIONAUX de la page 2026, sélectionnées par leur | |
| 298 | + section (« Opinion polls », « Campaign period »…) — aucune contrainte de | |
| 299 | + taille : la table de campagne est captée dès sa première rangée. Les | |
| 300 | + ventilations (langue, région, projections) sont exclues par section.""" | |
| 301 | + out, seen = [], set() | |
| 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 | 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 | 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 | 53 | from .pipeline import run_pipeline |
| 51 | 54 | await asyncio.sleep(60) |
| 52 | 55 | while True: |
@@ -55,7 +58,9 @@ async def _scheduler(): | ||
| 55 | 58 | log.info(f"pipeline: {report}") |
| 56 | 59 | except Exception as e: # le service continue même si un cycle échoue |
| 57 | 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 | 66 | @asynccontextmanager |
modified
backend/app/seed.py
+54 −11
@@ -93,8 +93,28 @@ def seed_all(db: Session | None = None) -> dict: | ||
| 93 | 93 | party=p, defaults=dict(pct=float(r[p]))) |
| 94 | 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 | 116 | # Sondages |
| 97 | − n_polls = 0 | |
| 117 | + n_polls, n_updated = 0, 0 | |
| 98 | 118 | for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)): |
| 99 | 119 | f = DATA_DIR / fname |
| 100 | 120 | if not f.exists(): |
@@ -107,30 +127,53 @@ def seed_all(db: Session | None = None) -> dict: | ||
| 107 | 127 | pol, _ = get_or_create(db, Mo.Pollster, name=pollster) |
| 108 | 128 | first = grp.iloc[0] |
| 109 | 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 | 132 | poll = (db.query(Mo.Poll) |
| 111 | 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 | 135 | if poll is None: |
| 114 | 136 | poll = Mo.Poll( |
| 115 | 137 | election_id=el.id, pollster_id=pol.id, |
| 116 | 138 | field_start=date.fromisoformat(first["field_start"]), |
| 117 | 139 | field_end=date.fromisoformat(fe), sample_size=ss, |
| 118 | − moe=None if pd.isna(first["moe"]) else float(first["moe"]), | |
| 119 | − population=first["population"], mode=first["mode"], | |
| 140 | + population=first["population"], | |
| 120 | 141 | region=first["region"], source_name=first["source_name"], |
| 121 | − source_url=first["source_url"], | |
| 122 | 142 | accessed_at=datetime.fromisoformat(first["accessed_at"])) |
| 123 | 143 | db.add(poll); db.flush() |
| 124 | 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 | 158 | total = grp["raw_value"].sum() |
| 159 | + by_party = {pr.party: pr for pr in poll.results} | |
| 160 | + changed = False | |
| 127 | 161 | for _, row in grp.iterrows(): |
| 128 | − if row["party"] in existing: | |
| 129 | − continue | |
| 130 | − poll.results.append(Mo.PollResult( | |
| 131 | − party=row["party"], raw_value=float(row["raw_value"]), | |
| 132 | − normalized_value=round(float(row["raw_value"]) * 100.0 / total, 3))) | |
| 162 | + raw = float(row["raw_value"]) | |
| 163 | + normalized = round(raw * 100.0 / total, 3) | |
| 164 | + pr = by_party.get(row["party"]) | |
| 165 | + if pr is None: | |
| 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 | 175 | stats["new_polls"] = n_polls |
| 176 | + stats["updated_polls"] = n_updated | |
| 134 | 177 | |
| 135 | 178 | # Événements clés |
| 136 | 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