# 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 """« Interroger le modèle » — explications traçables, fondées sur les données. Chaque affirmation provient d'une comparaison chiffrée entre deux runs du modèle (probabilités, sièges, vote), des sondages ajoutés entre les deux, du sentiment médiatique et des événements détectés. L'éventuel LLM ne fait que reformuler ces faits — jamais générer une prédiction. """ from __future__ import annotations from datetime import date, datetime, timedelta, timezone from sqlalchemy.orm import Session from ..config import settings from .. import models as Mo def _latest_run(db: Session, before: date | None = None) -> Mo.ForecastRun | None: q = db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False)) if before: q = q.filter(Mo.ForecastRun.as_of <= before) return q.order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first() def gather_facts(db: Session, days_back: int = 7) -> dict: """Faits comparables entre le run actuel et celui d'il y a ~days_back jours.""" cur = _latest_run(db) if cur is None: return {"error": "aucun forecast disponible"} prev = _latest_run(db, before=cur.as_of - timedelta(days=days_back)) facts: dict = {"as_of": cur.as_of.isoformat(), "run_id": cur.id, "compared_to": prev.as_of.isoformat() if prev else None, "changes": [], "new_polls": [], "events": [], "sentiment": {}} parties = [p for p in settings.parties if p != "AUT"] for p in parties: cur_s = cur.seats["per_party"][p] cur_f = cur.national["forecast"][p] change = {"party": p, "prob_most": cur_s["prob_most"], "prob_majority": cur_s["prob_majority"], "seats_mean": cur_s["mean"], "vote_mean": cur_f["mean"]} if prev: prev_s = prev.seats["per_party"].get(p, {}) prev_f = prev.national["forecast"].get(p, {}) change["d_prob_most"] = round(cur_s["prob_most"] - prev_s.get("prob_most", 0), 4) change["d_seats"] = round(cur_s["mean"] - prev_s.get("mean", 0), 1) change["d_vote"] = round(cur_f["mean"] - prev_f.get("mean", 0), 2) facts["changes"].append(change) since = cur.as_of - timedelta(days=days_back) polls = (db.query(Mo.Poll).filter(Mo.Poll.field_end > since, Mo.Poll.field_end <= cur.as_of, Mo.Poll.excluded.is_(False)).all()) for poll in polls: facts["new_polls"].append({ "pollster": poll.pollster.name, "field_end": poll.field_end.isoformat(), "n": poll.sample_size, "shares": {r.party: r.normalized_value for r in poll.results if r.party != "AUT"}}) for ev in (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date > since) .order_by(Mo.NewsEvent.importance.desc()).limit(6)): facts["events"].append({"date": ev.event_date.isoformat(), "title": ev.title, "kind": ev.kind, "parties": ev.parties}) cutoff = datetime.now(timezone.utc) - timedelta(days=days_back) for p in parties: rows = (db.query(Mo.SentimentScore).join(Mo.SentimentDocument) .filter(Mo.SentimentScore.entity == p, Mo.SentimentDocument.fetched_at >= cutoff).all()) if rows: facts["sentiment"][p] = { "volume": len(rows), "moyenne": round(sum(r.sentiment for r in rows) / len(rows), 3)} return facts def compose_answer(question: str, facts: dict) -> dict: """Réponse déterministe en français, chaque phrase traçable aux faits.""" if "error" in facts: return {"answer": "Le modèle n'a pas encore produit de forecast.", "facts": facts} q = question.lower() target = next((p for p in settings.parties if p.lower() in q or settings.party_names[p].lower() in q), None) lines = [] changes = {c["party"]: c for c in facts["changes"]} subjects = [target] if target else [c["party"] for c in sorted(facts["changes"], key=lambda c: -abs(c.get("d_prob_most", 0)))[:2]] for p in subjects: c = changes[p] name = settings.party_names[p] lines.append( f"{name} : {c['prob_most']*100:.0f} % de chances de remporter le plus de " f"sièges ({c['seats_mean']:.0f} sièges attendus, {c['vote_mean']:.1f} % du vote).") if "d_prob_most" in c and facts["compared_to"]: verb = "a augmenté" if c["d_prob_most"] > 0 else "a diminué" lines.append( f"Depuis le {facts['compared_to']}, cette probabilité {verb} de " f"{abs(c['d_prob_most'])*100:.0f} point(s) " f"(vote {c['d_vote']:+.1f} pp, sièges {c['d_seats']:+.0f}).") if facts["new_polls"]: ps = ", ".join(f"{x['pollster']} ({x['field_end']})" for x in facts["new_polls"][:4]) lines.append(f"Sondages intégrés récemment : {ps}.") if target: vals = [x["shares"].get(target) for x in facts["new_polls"] if x["shares"].get(target)] if vals: lines.append(f"Ces sondages placent {settings.party_names[target]} en moyenne " f"à {sum(vals)/len(vals):.1f} %.") if facts["events"]: lines.append("Événements récents : " + "; ".join(f"{e['title']} ({e['date']})" for e in facts["events"][:3]) + ".") if target and target in facts["sentiment"]: s = facts["sentiment"][target] tone = "plutôt positif" if s["moyenne"] > 0.1 else ( "plutôt négatif" if s["moyenne"] < -0.1 else "neutre") lines.append(f"Signal médiatique (auxiliaire, hors modèle) : {s['volume']} mentions, " f"ton {tone} ({s['moyenne']:+.2f}).") lines.append("Rappel : ces chiffres sont des probabilités, pas des certitudes.") return {"answer": " ".join(lines), "facts": facts}