spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
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