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%
9.6 KB · 211 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"""Moteur de sentiment politique — signal AUXILIAIRE, poids nul dans le forecast.67Pipeline : document → extraction d'entités (partis, chefs) → stance/sentiment8→ clustering (déduplication de la même nouvelle) → agrégats + anomalies de volume.910Deux scoreurs :11  * « lexicon » : lexique français + négation + heuristique de cible12    (une attaque de X contre Y est négative envers Y, pas envers X);13  * « llm » (optionnel, si MACLUSTR_API_KEY) : classification de stance par LLM.1415Le sentiment Web n'est PAS un sondage : il est affiché comme signal bruité et16n'entre jamais dans le calcul des probabilités électorales.17"""18from __future__ import annotations1920import hashlib21import re22from datetime import date, datetime, timedelta, timezone2324import numpy as np25from sqlalchemy.orm import Session2627from ..config import settings28from .. import models as Mo2930ENTITY_ALIASES: dict[str, list[str]] = {31    "CAQ": ["caq", "coalition avenir québec", "christine fréchette", "fréchette"],32    "PLQ": ["plq", "parti libéral du québec", "libéral", "libéraux", "charles milliard",33            "milliard"],34    "PQ": ["parti québécois", " pq ", "st-pierre plamondon", "plamondon", "pspp",35           "péquiste"],36    "QS": ["québec solidaire", " qs ", "ruba ghazal", "sol zanetti", "solidaire"],37    "PCQ": ["parti conservateur du québec", " pcq ", "éric duhaime", "duhaime",38            "conservateur du québec"],39}4041POS_WORDS = {"gagne", "succès", "hausse", "progresse", "populaire", "appui", "appuis",42             "victoire", "monte", "remonte", "convainc", "salue", "félicite", "record",43             "confiance", "promet", "espoir", "solide", "fort", "avance", "favori",44             "endosse", "rallie", "applaudit", "réussit", "améliore"}45NEG_WORDS = {"scandale", "chute", "recul", "critique", "critiqué", "controverse",46             "démission", "échec", "perd", "baisse", "colère", "crise", "attaque",47             "accuse", "accusé", "dérape", "conflit", "corruption", "enquête",48             "polémique", "déçoit", "impopulaire", "fragile", "faible", "menace",49             "dénonce", "erreur", "gaffe", "tollé", "démissionne", "poursuite"}50NEGATIONS = {"ne", "pas", "jamais", "sans", "aucun", "aucune", "ni", "non"}51ATTACK_VERBS = {"accuse", "attaque", "dénonce", "critique", "blâme", "reproche",52                "fustige", "tacle", "éreinte", "qualifie"}5354STOP = {"le", "la", "les", "de", "des", "du", "un", "une", "et", "en", "au", "aux",55        "pour", "sur", "dans", "avec", "que", "qui", "est", "sont", "a", "à", "d",56        "l", "se", "son", "sa", "ses", "plus", "pas", "ne", "il", "elle", "on"}575859def _tokens(text: str) -> list[str]:60    return re.findall(r"[a-zàâäéèêëîïôöùûüç'-]+", text.lower())616263def cluster_key(title: str) -> str:64    words = sorted(w for w in _tokens(title) if w not in STOP and len(w) > 3)[:6]65    return hashlib.sha1(" ".join(words).encode()).hexdigest()[:16]666768def detect_entities(text: str) -> list[str]:69    t = " " + text.lower() + " "70    return [code for code, aliases in ENTITY_ALIASES.items()71            if any(a in t for a in aliases)]727374def lexicon_score(text: str, entity: str) -> tuple[float, str]:75    """Sentiment [-1,1] + stance dirigés vers l'entité (heuristique de cible)."""76    toks = _tokens(text)77    aliases = ENTITY_ALIASES[entity]78    positions = [i for i, tok in enumerate(toks)79                 if any(a.strip().split()[0] in tok for a in aliases if a.strip())]80    score, n = 0.0, 081    for i, tok in enumerate(toks):82        val = 1.0 if tok in POS_WORDS else (-1.0 if tok in NEG_WORDS else 0.0)83        if val == 0.0:84            continue85        if any(toks[j] in NEGATIONS for j in range(max(0, i - 3), i)):86            val = -val87        # attaque politique : le sentiment négatif vise l'entité APRÈS le verbe88        if tok in ATTACK_VERBS:89            after = detect_entities(" ".join(toks[i:i + 12]))90            if entity not in after:91                continue  # l'attaque vise quelqu'un d'autre92        dist = min((abs(i - p) for p in positions), default=99)93        if dist > 25:94            continue  # trop loin de l'entité — probablement une autre cible95        score += val96        n += 197    if n == 0:98        return 0.0, "neutre"99    s = float(np.clip(score / max(n, 1), -1, 1))100    stance = "pro" if s > 0.25 else ("anti" if s < -0.25 else ("neutre" if abs(s) < 0.1 else "ambigu"))101    return s, stance102103104def llm_score(text: str, entity: str) -> tuple[float, str] | None:105    """Stance par LLM (MacLustr) — optionnel; retourne None en cas d'échec."""106    if not settings.maclustr_api_key:107        return None108    import httpx, json109    prompt = (110        "Tu analyses la couverture politique québécoise. Pour le texte suivant, "111        f"détermine la position (stance) envers {settings.party_names.get(entity, entity)}. "112        "Attention : sarcasme, négation, citations et attaques dirigées vers un adversaire. "113        "Réponds UNIQUEMENT en JSON: {\"stance\": \"pro|anti|neutre|ambigu\", "114        "\"sentiment\": nombre entre -1 et 1}.\n\nTexte: " + text[:1500])115    try:116        r = httpx.post(settings.maclustr_llm_url,117                       headers={"Authorization": f"Bearer {settings.maclustr_api_key}"},118                       json={"model": settings.maclustr_llm_model, "temperature": 0,119                             "messages": [{"role": "user", "content": prompt}],120                             "max_tokens": 100},121                       timeout=25)122        r.raise_for_status()123        content = r.json()["choices"][0]["message"]["content"]124        m = re.search(r"\{.*\}", content, re.S)125        data = json.loads(m.group())126        return float(np.clip(data["sentiment"], -1, 1)), str(data["stance"])127    except Exception:128        return None129130131def ingest_feeds(db: Session, feeds: list[str] | None = None) -> dict:132    import feedparser133    feeds = feeds or settings.sentiment_feeds134    added, scored = 0, 0135    for url in feeds:136        src = db.query(Mo.DataSource).filter_by(url=url).first()137        if src is None:138            src = Mo.DataSource(name=f"RSS {url.split('/')[2]}", url=url, kind="sentiment")139            db.add(src); db.flush()140        try:141            parsed = feedparser.parse(url)142            for e in parsed.entries[:60]:143                link = getattr(e, "link", None)144                title = getattr(e, "title", "") or ""145                if not link or not title:146                    continue147                if db.query(Mo.SentimentDocument).filter_by(url=link).first():148                    continue149                summary = re.sub(r"<[^>]+>", " ", getattr(e, "summary", "") or "")[:2000]150                pub = None151                if getattr(e, "published_parsed", None):152                    pub = datetime(*e.published_parsed[:6], tzinfo=timezone.utc)153                doc = Mo.SentimentDocument(154                    source=parsed.feed.get("title", url.split("/")[2]),155                    url=link, title=title, summary=summary,156                    published=pub, cluster_key=cluster_key(title))157                text = f"{title}. {summary}"158                entities = detect_entities(text)159                for ent in entities:160                    res = llm_score(text, ent)161                    method = "llm"162                    if res is None:163                        res, method = lexicon_score(text, ent), "lexicon"164                    s, stance = res165                    doc.scores.append(Mo.SentimentScore(166                        entity=ent, sentiment=s, stance=stance, method=method))167                    scored += 1168                db.add(doc)169                added += 1170            src.last_fetch = datetime.now(timezone.utc)171            src.last_status = "ok"172        except Exception as e:173            src.last_fetch = datetime.now(timezone.utc)174            src.last_status = f"error: {e}"[:200]175        db.commit()176    detect_volume_anomalies(db)177    return {"documents_ajoutés": added, "scores": scored}178179180def detect_volume_anomalies(db: Session, z_threshold: float = 2.5) -> int:181    """Anomalies de volume médiatique par parti (z-score vs 14 jours) → événements."""182    today = date.today()183    created = 0184    for party in ENTITY_ALIASES:185        counts = []186        for back in range(15):187            d = today - timedelta(days=back)188            n = (db.query(Mo.SentimentScore).join(Mo.SentimentDocument)189                 .filter(Mo.SentimentScore.entity == party,190                         Mo.SentimentDocument.published >= datetime(d.year, d.month, d.day, tzinfo=timezone.utc),191                         Mo.SentimentDocument.published < datetime(d.year, d.month, d.day, tzinfo=timezone.utc) + timedelta(days=1))192                 .count())193            counts.append(n)194        base = counts[1:]195        if len(base) < 7 or np.std(base) == 0:196            continue197        z = (counts[0] - np.mean(base)) / np.std(base)198        if z >= z_threshold and counts[0] >= 5:199            exists = (db.query(Mo.NewsEvent)200                      .filter_by(event_date=today, kind="anomalie-volume").count())201            if not exists:202                db.add(Mo.NewsEvent(203                    event_date=today, kind="anomalie-volume",204                    title=f"Pic de couverture médiatique — {settings.party_names[party]}",205                    description=f"Volume {counts[0]} vs moyenne {np.mean(base):.1f} (z={z:.1f})",206                    parties=[party], importance=min(0.9, 0.4 + z / 10),207                    detected_by="anomaly-detector"))208                created += 1209    db.commit()210    return created211