# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Social Pulse inter-plateformes (v3 « 127 », §13-§15). Transforme les publications brutes (`social_posts`) en métriques STANDARDISÉES et comparables entre plateformes — jamais en intentions de vote : * attention_z : volume 24 h vs base 28 j, par parti (z-score); * velocity_z : accélération (24 h vs moyenne 3 j); * breadth : diversité des contenus (1 − part du texte dominant); * cross_platform: nombre de plateformes qui confirment le signal; * stance_balance: (pro − anti) / total, pondéré engagement; * quality : part d'attention ORGANIQUE après filtrage de la coordination (§14 : near-duplicates, rafales synchronisées, concentration d'engagement). Avertissement affiché partout : les signaux sociaux mesurent l'attention et le discours en ligne — ils ne constituent PAS un sondage représentatif. """ from __future__ import annotations import math from collections import Counter from datetime import datetime, timedelta, timezone import numpy as np from sqlalchemy.orm import Session from ...config import settings from ... import models as Mo BASELINE_DAYS = 28 def _posts(db: Session, days: int) -> list: cutoff = datetime.now(timezone.utc) - timedelta(days=days) return (db.query(Mo.SocialPost) .filter(Mo.SocialPost.fetched_at >= cutoff).all()) def coordination_filter(posts: list) -> tuple[list, dict]: """Sépare attention organique et coordonnée (heuristiques §14) : - grappes de quasi-doublons (même text_hash déjà dédupliqué en amont; ici : mêmes 4 premiers mots × ≥3 occurrences); - rafales synchronisées (≥5 posts d'une même grappe dans la même heure); - concentration d'engagement (>60 % de l'engagement d'un parti porté par <5 % des posts → part marquée incertaine, pas rejetée).""" by_prefix: dict[str, list] = {} for p in posts: prefix = " ".join((p.text or "").lower().split()[:4]) by_prefix.setdefault(prefix, []).append(p) organic, coordinated = [], [] for prefix, group in by_prefix.items(): if len(group) >= 3: hours = Counter((p.fetched_at or datetime.now(timezone.utc)) .strftime("%Y%m%d%H") for p in group) if max(hours.values()) >= 5 or len(group) >= 6: coordinated.extend(group) continue organic.extend(group) diag = {"n_organic": len(organic), "n_coordinated": len(coordinated), "organic_share": round(len(organic) / max(1, len(posts)), 3)} return organic, diag def _party_daily_volumes(posts: list, party: str) -> dict[str, float]: out: dict[str, float] = {} for p in posts: if party in (p.scores or {}): d = (p.created_at or p.fetched_at).strftime("%Y-%m-%d") out[d] = out.get(d, 0.0) + 1.0 return out def pulse_index(db: Session) -> dict: """Indice Social Pulse par parti (attention, vélocité, stance, largeur, confirmation inter-plateformes, qualité).""" posts = _posts(db, BASELINE_DAYS) if len(posts) < 30: return {"available": False, "note": "corpus social insuffisant (premiers cycles)"} organic, coord_diag = coordination_filter(posts) now = datetime.now(timezone.utc) parties = [p for p in settings.parties if p != "AUT"] out = {} for party in parties: mine = [p for p in organic if party in (p.scores or {})] vols = _party_daily_volumes(organic, party) days = [(now - timedelta(days=i)).strftime("%Y-%m-%d") for i in range(BASELINE_DAYS)] series = np.array([vols.get(d, 0.0) for d in days]) # [0]=aujourd'hui base = series[3:] mu, sd = float(base.mean()), float(base.std() or 1.0) attention_z = (float(series[0]) - mu) / sd recent3 = float(series[:3].mean()) velocity_z = (float(series[0]) - recent3) / (sd or 1.0) # stance pondérée engagement wsum = wtot = 0.0 pro = anti = 0 platforms = set() for p in mine: sc = p.scores[party] w = 1.0 + math.log1p(max(0.0, p.engagement)) wsum += (1 if sc["stance"] == "pro" else -1 if sc["stance"] == "anti" else 0) * w wtot += w pro += sc["stance"] == "pro" anti += sc["stance"] == "anti" platforms.add(p.platform) texts = Counter(" ".join((p.text or "").lower().split()[:4]) for p in mine) breadth = round(1.0 - (max(texts.values()) / len(mine) if mine else 0), 3) out[party] = { "attention_z": round(attention_z, 2), "velocity_z": round(velocity_z, 2), "volume_24h": int(series[0]), "volume_28j": int(series.sum()), "stance_balance": round(wsum / wtot, 3) if wtot else None, "pro": pro, "anti": anti, "breadth": breadth, "cross_platform": len(platforms), "platforms": sorted(platforms), } return {"available": True, "parties": out, "quality": coord_diag, "as_of": now.isoformat(), "note": ("Les signaux sociaux mesurent l'attention et le discours " "public en ligne — ils ne constituent PAS un sondage " "représentatif. Poids directionnel : nul.")}