SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
4.0 KB · 87 lines python
Raw Blame History
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Event Engine (v3 « 127 », §17) — détection d'événements de campagne par6CONFIRMATION CROISÉE : une anomalie de volume dans la presse OU le social ne7devient un « événement politique » que si au moins deux familles de sources la8confirment dans la même fenêtre (presse RSS/Firecrawl, médias sociaux,9attention Wikipédia).1011L'événement détecté n'a AUCUN effet directionnel présumé : il est publié dans12la chronologie et peut justifier temporairement la hausse (déjà bornée) de la13variance de campagne via signals/volatility."""14from __future__ import annotations1516from datetime import date, datetime, timedelta, timezone1718import numpy as np19from sqlalchemy.orm import Session2021from ...config import settings22from ... import models as Mo232425def _z_today(counts: list[float]) -> float:26    base = counts[1:]27    if len(base) < 7 or np.std(base) == 0:28        return 0.029    return float((counts[0] - np.mean(base)) / np.std(base))303132def detect_events(db: Session, z_threshold: float = 2.0) -> int:33    """Anomalies confirmées inter-familles → NewsEvent kind « événement-confirmé »."""34    today = date.today()35    now = datetime.now(timezone.utc)36    created = 037    for party in [p for p in settings.parties if p != "AUT"]:38        # famille presse (SentimentScore) et famille sociale (SocialPost)39        press, social = [], []40        for back in range(15):41            d0 = datetime(today.year, today.month, today.day,42                          tzinfo=timezone.utc) - timedelta(days=back)43            d1 = d0 + timedelta(days=1)44            press.append(db.query(Mo.SentimentScore).join(Mo.SentimentDocument)45                         .filter(Mo.SentimentScore.entity == party,46                                 Mo.SentimentDocument.fetched_at >= d0,47                                 Mo.SentimentDocument.fetched_at < d1).count())48            n_soc = 049            for post in (db.query(Mo.SocialPost)50                         .filter(Mo.SocialPost.fetched_at >= d0,51                                 Mo.SocialPost.fetched_at < d1).all()):52                if party in (post.scores or {}):53                    n_soc += 154            social.append(n_soc)55        zp, zs = _z_today([float(x) for x in press]), _z_today([float(x) for x in social])56        # attention wikipédia du jour (déjà en base via wiki_attention)57        zw = 0.058        row = (db.query(Mo.Indicator)59               .filter(Mo.Indicator.name == f"wiki_att_{party}")60               .order_by(Mo.Indicator.as_of.desc()).limit(30).all())61        if len(row) >= 10:62            vals = np.array([r.value for r in reversed(row)])63            sd = float(vals[:-1].std() or 1.0)64            zw = float((vals[-1] - vals[:-1].mean()) / sd)65        confirmations = sum(1 for z in (zp, zs, zw) if z >= z_threshold)66        if confirmations >= 2:67            exists = (db.query(Mo.NewsEvent)68                      .filter(Mo.NewsEvent.event_date == today,69                              Mo.NewsEvent.kind == "événement-confirmé")70                      .filter(Mo.NewsEvent.title.contains(71                          settings.party_names[party])).count())72            if not exists:73                db.add(Mo.NewsEvent(74                    event_date=today, kind="événement-confirmé",75                    title=(f"Événement de campagne détecté — "76                           f"{settings.party_names[party]}"),77                    description=(f"Confirmation croisée ({confirmations}/3 familles) : "78                                 f"presse z={zp:.1f}, social z={zs:.1f}, "79                                 f"Wikipédia z={zw:.1f}. Effet directionnel non "80                                 f"présumé; variance de campagne déjà bornée."),81                    parties=[party],82                    importance=min(0.95, 0.5 + 0.1 * confirmations),83                    detected_by="event-engine"))84                created += 185    db.commit()86    return created87