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%
6.1 KB · 123 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"""« Interroger le modèle » — explications traçables, fondées sur les données.67Chaque affirmation provient d'une comparaison chiffrée entre deux runs du8modèle (probabilités, sièges, vote), des sondages ajoutés entre les deux, du9sentiment médiatique et des événements détectés. L'éventuel LLM ne fait que10reformuler ces faits — jamais générer une prédiction.11"""12from __future__ import annotations1314from datetime import date, datetime, timedelta, timezone1516from sqlalchemy.orm import Session1718from ..config import settings19from .. import models as Mo202122def _latest_run(db: Session, before: date | None = None) -> Mo.ForecastRun | None:23    q = db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False))24    if before:25        q = q.filter(Mo.ForecastRun.as_of <= before)26    return q.order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first()272829def gather_facts(db: Session, days_back: int = 7) -> dict:30    """Faits comparables entre le run actuel et celui d'il y a ~days_back jours."""31    cur = _latest_run(db)32    if cur is None:33        return {"error": "aucun forecast disponible"}34    prev = _latest_run(db, before=cur.as_of - timedelta(days=days_back))35    facts: dict = {"as_of": cur.as_of.isoformat(), "run_id": cur.id,36                   "compared_to": prev.as_of.isoformat() if prev else None,37                   "changes": [], "new_polls": [], "events": [], "sentiment": {}}3839    parties = [p for p in settings.parties if p != "AUT"]40    for p in parties:41        cur_s = cur.seats["per_party"][p]42        cur_f = cur.national["forecast"][p]43        change = {"party": p, "prob_most": cur_s["prob_most"],44                  "prob_majority": cur_s["prob_majority"],45                  "seats_mean": cur_s["mean"], "vote_mean": cur_f["mean"]}46        if prev:47            prev_s = prev.seats["per_party"].get(p, {})48            prev_f = prev.national["forecast"].get(p, {})49            change["d_prob_most"] = round(cur_s["prob_most"] - prev_s.get("prob_most", 0), 4)50            change["d_seats"] = round(cur_s["mean"] - prev_s.get("mean", 0), 1)51            change["d_vote"] = round(cur_f["mean"] - prev_f.get("mean", 0), 2)52        facts["changes"].append(change)5354    since = cur.as_of - timedelta(days=days_back)55    polls = (db.query(Mo.Poll).filter(Mo.Poll.field_end > since,56                                      Mo.Poll.field_end <= cur.as_of,57                                      Mo.Poll.excluded.is_(False)).all())58    for poll in polls:59        facts["new_polls"].append({60            "pollster": poll.pollster.name, "field_end": poll.field_end.isoformat(),61            "n": poll.sample_size,62            "shares": {r.party: r.normalized_value for r in poll.results if r.party != "AUT"}})6364    for ev in (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date > since)65               .order_by(Mo.NewsEvent.importance.desc()).limit(6)):66        facts["events"].append({"date": ev.event_date.isoformat(), "title": ev.title,67                                "kind": ev.kind, "parties": ev.parties})6869    cutoff = datetime.now(timezone.utc) - timedelta(days=days_back)70    for p in parties:71        rows = (db.query(Mo.SentimentScore).join(Mo.SentimentDocument)72                .filter(Mo.SentimentScore.entity == p,73                        Mo.SentimentDocument.fetched_at >= cutoff).all())74        if rows:75            facts["sentiment"][p] = {76                "volume": len(rows),77                "moyenne": round(sum(r.sentiment for r in rows) / len(rows), 3)}78    return facts798081def compose_answer(question: str, facts: dict) -> dict:82    """Réponse déterministe en français, chaque phrase traçable aux faits."""83    if "error" in facts:84        return {"answer": "Le modèle n'a pas encore produit de forecast.", "facts": facts}85    q = question.lower()86    target = next((p for p in settings.parties87                   if p.lower() in q or settings.party_names[p].lower() in q), None)88    lines = []89    changes = {c["party"]: c for c in facts["changes"]}90    subjects = [target] if target else [c["party"] for c in91                sorted(facts["changes"], key=lambda c: -abs(c.get("d_prob_most", 0)))[:2]]92    for p in subjects:93        c = changes[p]94        name = settings.party_names[p]95        lines.append(96            f"{name} : {c['prob_most']*100:.0f} % de chances de remporter le plus de "97            f"sièges ({c['seats_mean']:.0f} sièges attendus, {c['vote_mean']:.1f} % du vote).")98        if "d_prob_most" in c and facts["compared_to"]:99            verb = "a augmenté" if c["d_prob_most"] > 0 else "a diminué"100            lines.append(101                f"Depuis le {facts['compared_to']}, cette probabilité {verb} de "102                f"{abs(c['d_prob_most'])*100:.0f} point(s) "103                f"(vote {c['d_vote']:+.1f} pp, sièges {c['d_seats']:+.0f}).")104    if facts["new_polls"]:105        ps = ", ".join(f"{x['pollster']} ({x['field_end']})" for x in facts["new_polls"][:4])106        lines.append(f"Sondages intégrés récemment : {ps}.")107        if target:108            vals = [x["shares"].get(target) for x in facts["new_polls"] if x["shares"].get(target)]109            if vals:110                lines.append(f"Ces sondages placent {settings.party_names[target]} en moyenne "111                             f"à {sum(vals)/len(vals):.1f} %.")112    if facts["events"]:113        lines.append("Événements récents : "114                     + "; ".join(f"{e['title']} ({e['date']})" for e in facts["events"][:3]) + ".")115    if target and target in facts["sentiment"]:116        s = facts["sentiment"][target]117        tone = "plutôt positif" if s["moyenne"] > 0.1 else (118            "plutôt négatif" if s["moyenne"] < -0.1 else "neutre")119        lines.append(f"Signal médiatique (auxiliaire, hors modèle) : {s['volume']} mentions, "120                     f"ton {tone} ({s['moyenne']:+.2f}).")121    lines.append("Rappel : ces chiffres sont des probabilités, pas des certitudes.")122    return {"answer": " ".join(lines), "facts": facts}123