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 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
Menu

Theme