# 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 """API publique — lecture du forecast, sondages, circonscriptions, sentiment.""" from __future__ import annotations import json from datetime import date, datetime, timedelta, timezone import numpy as np from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from ..db import get_db from .. import models as Mo from ..modeling import simulate as SIM from ..modeling.explain import compose_answer, gather_facts from ..modeling.house_effects import PollsterProfile, adjust_shares, profile_for from ..modeling.weights import display_weight from ..pipeline import _district_inputs router = APIRouter(prefix="/api") def _latest_run(db: Session) -> Mo.ForecastRun: run = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False)) .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first()) if run is None: raise HTTPException(503, "Aucun forecast disponible — lancez le pipeline.") return run @router.get("/meta") def meta(db: Session = Depends(get_db)): target = db.query(Mo.Election).filter_by(is_target=True).first() parties = db.query(Mo.Party).all() last = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False)) .order_by(Mo.ForecastRun.run_at.desc()).first()) return { "app": settings.app_name, "election": {"name": target.name if target else settings.election_name, "date": settings.election_date.isoformat(), "total_seats": settings.total_seats, "majority_seats": settings.majority_seats, "days_remaining": (settings.election_date - date.today()).days}, "model_version": settings.model_version, "last_update": last.run_at.isoformat() if last else None, "parties": {p.code: {"name": p.name, "leader": p.leader, "color": p.color} for p in parties}, } @router.get("/forecast/latest") def forecast_latest(db: Session = Depends(get_db)): run = _latest_run(db) return {"run_id": run.id, "as_of": run.as_of.isoformat(), "run_at": run.run_at.isoformat(), "model_version": run.model_version, "n_polls_used": run.n_polls_used, "n_simulations": run.n_simulations, "national": run.national, "seats": run.seats, "diagnostics": run.diagnostics} @router.get("/forecast/history") def forecast_history(db: Session = Depends(get_db)): """Évolution des probabilités et votes à travers les runs (Forecast Explorer).""" runs = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False)) .order_by(Mo.ForecastRun.as_of.asc(), Mo.ForecastRun.id.asc()).all()) seen: dict[str, Mo.ForecastRun] = {} for r in runs: seen[r.as_of.isoformat()] = r # dernier run de chaque journée out = [] for iso, r in sorted(seen.items()): out.append({ "as_of": iso, "run_id": r.id, "prob_most": {p: r.seats["per_party"][p]["prob_most"] for p in settings.parties}, "prob_majority": {p: r.seats["per_party"][p]["prob_majority"] for p in settings.parties}, "seats_mean": {p: r.seats["per_party"][p]["mean"] for p in settings.parties}, "vote_mean": {p: r.national["forecast"][p]["mean"] for p in settings.parties}, }) return out @router.get("/forecast/run/{run_id}") def forecast_run(run_id: int, db: Session = Depends(get_db)): run = db.get(Mo.ForecastRun, run_id) if run is None: raise HTTPException(404, "Run introuvable") return {"run_id": run.id, "as_of": run.as_of.isoformat(), "label": run.label, "is_backtest": run.is_backtest, "national": run.national, "seats": run.seats, "diagnostics": run.diagnostics} @router.get("/districts") def districts(db: Session = Depends(get_db)): run = _latest_run(db) static = {d.name: d for d in db.query(Mo.District).all()} out = [] for fr in run.district_results: d = static.get(fr.district_name) out.append({ **fr.detail, "incumbent_party": d.incumbent_party if d else None, "incumbent_running": d.incumbent_running if d else None, "baseline_2022": d.baseline_shares if d else None, "turnout_2022": d.turnout_2022 if d else None, "is_new_2026": d.is_new_2026 if d else False, }) return {"as_of": run.as_of.isoformat(), "run_id": run.id, "districts": out} @router.get("/districts/{name}") def district_detail(name: str, db: Session = Depends(get_db)): d = db.query(Mo.District).filter(Mo.District.name == name).first() if d is None: raise HTTPException(404, "Circonscription introuvable") run = _latest_run(db) fr = next((x for x in run.district_results if x.district_name == name), None) hist = (db.query(Mo.HistoricalDistrictResult) .filter_by(district_name=name).all()) candidates = db.query(Mo.Candidate).filter_by(district_id=d.id).all() return {"name": d.name, "region": d.region, "baseline_2022": d.baseline_shares, "baseline_source": d.baseline_source, "incumbent_party": d.incumbent_party, "incumbent_running": d.incumbent_running, "turnout_2022": d.turnout_2022, "is_new_2026": d.is_new_2026, "forecast": fr.detail if fr else None, "candidates": [{"name": c.name, "party": c.party, "party_full": c.party_full, "is_incumbent": c.is_incumbent} for c in candidates], "history": [{"party": h.party, "pct": h.pct} for h in hist]} @router.get("/geo") def geo(): """Carte électorale officielle 2026 (GeoJSON DGEQ, propriétés minimales).""" f = DATA_DIR / "carte_2026.geojson" if not f.exists(): raise HTTPException(404, "Carte non téléchargée — lancez le pipeline.") from fastapi.responses import FileResponse return FileResponse(f, media_type="application/geo+json", headers={"Cache-Control": "public, max-age=86400"}) @router.get("/markets") def markets(db: Session = Depends(get_db)): """Marchés prédictifs (signal auxiliaire) + comparaison au modèle.""" from ..ingest.markets import fetch_winner_market data = fetch_winner_market() if data is None: raise HTTPException(503, "Marchés indisponibles") try: run = _latest_run(db) data["model_prob_most_seats"] = { p: run.seats["per_party"][p]["prob_most"] for p in settings.parties if p in data["implied_prob_most_seats"]} except HTTPException: pass return data @router.get("/beyond") def beyond(db: Session = Depends(get_db)): """v2 « Au-delà des sondages » : décomposition du forecast par couche (sondages / partielles / fondamentaux / médias / marchés), prior de fondamentaux, satisfaction gouvernementale, poids selon l'horizon.""" run = _latest_run(db) from ..modeling.fundamentals import blend_weight days = run.diagnostics.get("days_to_election", 0) schedule = [{"days": d, "weight": round(blend_weight(d), 4)} for d in [540, 450, 365, 270, 180, 120, 90, 60, 30, 14, 7, 1, 0]] n_bye = run.diagnostics.get("n_byelections", 0) return {"run_id": run.id, "as_of": run.as_of.isoformat(), "model_version": run.model_version, "days_to_election": days, "n_polls": run.n_polls_used - n_bye, "n_byelections": n_bye, "beyond": run.national.get("beyond") or {}, "fundamentals_weight_schedule": schedule} @router.get("/byelections") def byelections(db: Session = Depends(get_db)): """Partielles 2022-2026 : votes réels + observation nationale implicite.""" from ..ingest.byelections import pseudo_polls pseudo = {p["pollster"]: p for p in pseudo_polls(db)} out = [] for b in (db.query(Mo.ByElection).order_by(Mo.ByElection.held_on.asc()).all()): ps = pseudo.get(f"Vote réel — {b.district_name}") out.append({"district": b.district_name, "held_on": b.held_on.isoformat(), "result": b.result, "turnout": b.turnout, "winner": b.winner, "previous_winner": b.previous_winner, "verified": b.verified, "used_in_model": b.used_in_model, "source_url": b.source_url, "notes": b.notes, "implied_national": ps["shares"] if ps else None, "equivalent_n": settings.byelection_equivalent_n, "swing_shrink": settings.byelection_swing_shrink}) return out @router.get("/signals") def signals(db: Session = Depends(get_db), kind: str | None = None, limit: int = Query(60, le=300)): """Veille web continue (Firecrawl) : radar sondages, satisfaction, presse.""" q = db.query(Mo.WebSignal).order_by(Mo.WebSignal.detected_at.desc()) if kind: q = q.filter(Mo.WebSignal.kind == kind) rows = q.limit(limit).all() sat = (db.query(Mo.Indicator).filter_by(name="gov_satisfaction") .order_by(Mo.Indicator.as_of.desc()).first()) return { "enabled": bool(settings.firecrawl_api_key) and settings.firecrawl_enabled, "satisfaction": ({"value": sat.value, "as_of": sat.as_of.isoformat(), "source": sat.source, "source_url": sat.source_url, "method": sat.method} if sat else {"value": settings.gov_satisfaction_fallback, "method": "fallback-config"}), "signals": [{"detected_at": s.detected_at.isoformat(), "kind": s.kind, "title": s.title, "url": s.url, "snippet": s.snippet, "pollster": s.pollster, "status": s.status, "extra": s.extra} for s in rows]} @router.get("/social") def social(db: Session = Depends(get_db), days: int = Query(14, le=60)): """Pouls social continu (YouTube/Reddit/Mastodon/Lemmy + engagement des comptes officiels via acteur Apify maison) — poids nul dans le forecast.""" from ..ingest.social_pulse import pulse_summary from ..modeling.signals.social import pulse_index out = pulse_summary(db, days=days) out["pulse_index"] = pulse_index(db) return out @router.get("/battlegrounds") def battlegrounds(db: Session = Depends(get_db)): """Circonscriptions pivots (v3 §24) : P(fait basculer la majorité) par la méthode des simulations + champs de bataille (chaudement disputés).""" run = _latest_run(db) tips = (run.seats or {}).get("battlegrounds") contested = sorted((fr for fr in run.district_results if fr.category == "Chaudement disputé"), key=lambda fr: -max(fr.detail["win_probs"].values())) # Course à 64 : pour chaque parti, circonscriptions classées de la plus # sûre à la plus décisive; la 64ᵉ est le « siège de la majorité ». paths = {} for party in [p for p in settings.parties if p != "AUT"]: ranked = sorted(run.district_results, key=lambda fr: -fr.detail["win_probs"].get(party, 0.0)) rows = [] for i, fr in enumerate(ranked[:80], start=1): wp = fr.detail["win_probs"].get(party, 0.0) exp = fr.detail.get("expected") or {} top2 = sorted(exp.values(), reverse=True)[:2] rows.append({"rank": i, "district": fr.district_name, "win_prob": wp, "margin_pp": round(top2[0] - top2[1], 1) if len(top2) == 2 else None, "favorite": fr.favorite, "is_majority_seat": i == settings.majority_seats}) seat64 = rows[settings.majority_seats - 1] if len(rows) >= settings.majority_seats else None paths[party] = {"seats_ge_50": sum(1 for r in rows if r["win_prob"] >= 0.5), "majority_seat": seat64, "ladder": rows} return {"run_id": run.id, "as_of": run.as_of.isoformat(), "tipping": tips, "paths_to_64": paths, "contested": [{"district": fr.district_name, "favorite": fr.favorite, "win_probs": fr.detail["win_probs"], "region": fr.detail.get("region")} for fr in contested]} @router.get("/ablation") def ablation_report(): """Dernier rapport d'ablation (v3 §22) — quelle couche apporte quoi.""" f = DATA_DIR / "ablation_report.json" if not f.exists(): raise HTTPException(404, "Ablation non exécutée — POST /api/admin/ablation") return json.loads(f.read_text()) @router.get("/demographics/{name}") def demographics(name: str): """Démographie officielle de la circonscription (Recensement 2021 sur les limites 2026, classeur Élections Québec) + circonscriptions semblables.""" from ..ingest.eq_socioeconomic import POP_DIR, slug_of f = POP_DIR / "portraits" / f"{slug_of(name)}.json" if not f.exists(): raise HTTPException(404, "Démographie non disponible") data = json.loads(f.read_text()) simf = POP_DIR / "riding_similarity.json" if simf.exists(): data["similar"] = json.loads(simf.read_text()).get(data["district"], [])[:6] return data @router.get("/election-night") def election_night(): """État du modèle du soir d'élection (dormant avant le 5 octobre 2026).""" from ..modeling.election_night import latest_state state = latest_state() if state is None: raise HTTPException(404, "Le soir d'élection n'a pas commencé — le " "modèle s'activera avec les premiers résultats " "officiels du DGEQ le 5 octobre 2026.") return state @router.get("/replay") def replay_report(): """Replay historique multi-élections (2007-2022, LOEO strict, §52-54).""" f = DATA_DIR / "replay_report.json" if not f.exists(): raise HTTPException(404, "Replay non exécuté — POST /api/admin/replay") return json.loads(f.read_text()) @router.get("/pollster-error") def pollster_error_report(): """Modèle d'erreur des sondeurs (§6-10) : erreurs d'industrie par élection, σ_industrie estimé LOEO, house effects hiérarchiques.""" from ..modeling.national.pollster_error import report return report() @router.get("/attention") def attention(db: Session = Depends(get_db)): """Attention Wikipédia : parts, tendances, turbulence (signal non directionnel).""" from ..ingest.wiki_attention import signal return signal(db) @router.get("/synthetic") def synthetic(db: Session = Depends(get_db)): """Dernier sondage synthétique LLM — EXPÉRIMENTAL, poids nul dans le forecast.""" from ..modeling.synthetic_poll import latest data = latest(db) if data is None: raise HTTPException(404, "Aucun sondage synthétique généré pour l'instant") try: run = _latest_run(db) data["forecast_comparison"] = { p: {"synthetique": data["shares"].get(p), "forecast": run.national["forecast"][p]["mean"]} for p in settings.parties if data.get("shares")} except HTTPException: pass return data @router.get("/today") def today(db: Session = Depends(get_db)): """« Le point du jour » : forecast daté + ce qui a changé depuis la veille.""" run = _latest_run(db) # dernier run d'une journée antérieure (comparaison « depuis hier ») prev = (db.query(Mo.ForecastRun) .filter(Mo.ForecastRun.is_backtest.is_(False), Mo.ForecastRun.as_of < run.as_of) .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first()) def probs(r): ens = (r.seats.get("ensemble") or {}).get("blended") if r.seats else None return {p: (ens or {}).get(p, r.seats["per_party"][p]["prob_most"]) for p in settings.parties} cur_p, cur_v = probs(run), {p: run.national["forecast"][p]["mean"] for p in settings.parties} cur_s = {p: run.seats["per_party"][p]["mean"] for p in settings.parties} delta = None if prev: pv = {p: prev.national["forecast"][p]["mean"] for p in settings.parties} pp = probs(prev) ps = {p: prev.seats["per_party"][p]["mean"] for p in settings.parties} delta = {"since": prev.as_of.isoformat(), "prob_most": {p: round(cur_p[p] - pp[p], 4) for p in settings.parties}, "vote": {p: round(cur_v[p] - pv[p], 2) for p in settings.parties}, "seats": {p: round(cur_s[p] - ps[p], 1) for p in settings.parties}} # nouveautés des dernières 24 h day_ago = datetime.now(timezone.utc) - timedelta(hours=24) new_polls = (db.query(Mo.Poll).filter(Mo.Poll.accessed_at >= day_ago) .order_by(Mo.Poll.field_end.desc()).limit(10).all()) new_signals = (db.query(Mo.WebSignal).filter(Mo.WebSignal.detected_at >= day_ago) .order_by(Mo.WebSignal.detected_at.desc()).limit(12).all()) upcoming = (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date >= date.today()) .order_by(Mo.NewsEvent.event_date.asc()).limit(5).all()) target = db.query(Mo.Election).filter_by(is_target=True).first() B = run.national.get("beyond") or {} return { "date": date.today().isoformat(), "as_of": run.as_of.isoformat(), "run_id": run.id, "run_at": run.run_at.isoformat(), "model_version": run.model_version, "days_to_election": (target.election_date - date.today()).days if target else None, "prob_most": cur_p, "vote": cur_v, "seats": {p: {"mean": cur_s[p], "p05": run.seats["per_party"][p]["p05"], "p95": run.seats["per_party"][p]["p95"], "prob_majority": run.seats["per_party"][p]["prob_majority"]} for p in settings.parties}, "prob_no_majority": run.seats["summary"]["prob_no_majority"], "delta": delta, "beyond": {"web_attention": B.get("web_attention"), "media_adjustment": B.get("media_adjustment"), "market_ensemble": B.get("market_ensemble"), "fundamentals": {"satisfaction": (B.get("fundamentals") or {}).get("satisfaction"), "blend": (B.get("fundamentals") or {}).get("blend")}, "n_byelections": len(B.get("byelections_used") or [])}, "new_polls_24h": [{"pollster": p.pollster.name, "field_end": p.field_end.isoformat(), "sample_size": p.sample_size, "shares": {r.party: r.normalized_value for r in p.results}} for p in new_polls], "new_signals_24h": [{"kind": s.kind, "title": s.title, "url": s.url, "pollster": s.pollster} for s in new_signals], "upcoming_events": [{"date": e.event_date.isoformat(), "title": e.title, "kind": e.kind} for e in upcoming], } @router.get("/polls") def polls(db: Session = Depends(get_db), date_from: date | None = None, date_to: date | None = None, pollster: str | None = None, election: str = "2026", limit: int = Query(500, le=2000)): el = (db.query(Mo.Election) .filter(Mo.Election.name.contains(election)).first()) q = db.query(Mo.Poll).filter(Mo.Poll.election_id == el.id) if el else db.query(Mo.Poll) if date_from: q = q.filter(Mo.Poll.field_end >= date_from) if date_to: q = q.filter(Mo.Poll.field_end <= date_to) if pollster: q = q.join(Mo.Pollster).filter(Mo.Pollster.name == pollster) rows = q.order_by(Mo.Poll.field_end.desc()).limit(limit).all() ratings = {r.pollster.name: r for r in db.query(Mo.PollsterRating).all()} as_of = date.today() out, max_w = [], 1e-9 for poll in rows: rating = ratings.get(poll.pollster.name) prof = PollsterProfile( name=poll.pollster.name, mae_pp=rating.mae_pp if rating else None, house_effects=rating.house_effects if rating else {}, weight_multiplier=rating.weight_multiplier if rating else 0.9) shares = {r.party: r.normalized_value for r in poll.results} adj = adjust_shares(shares, prof) if shares else {} w = display_weight(poll.field_end, as_of, poll.sample_size, poll.mode, prof.weight_multiplier) max_w = max(max_w, w) out.append({ "id": poll.id, "pollster": poll.pollster.name, "sponsor": poll.sponsor, "field_start": poll.field_start.isoformat() if poll.field_start else None, "field_end": poll.field_end.isoformat(), "sample_size": poll.sample_size, "moe": poll.moe, "mode": poll.mode, "population": poll.population, "source_url": poll.source_url, "source_name": poll.source_name, "excluded": poll.excluded, "raw": {r.party: r.raw_value for r in poll.results}, "normalized": shares, "adjusted": {k: round(v, 1) for k, v in adj.items()}, "weight": w, }) for r in out: r["weight"] = round(r["weight"] / max_w, 4) return out @router.get("/pollsters") def pollsters(db: Session = Depends(get_db)): out = [] for p in db.query(Mo.Pollster).all(): rating = (db.query(Mo.PollsterRating).filter_by(pollster_id=p.id) .order_by(Mo.PollsterRating.computed_at.desc()).first()) n = db.query(Mo.Poll).filter_by(pollster_id=p.id).count() out.append({"name": p.name, "polls_in_db": n, "rating": {"n_final_polls": rating.n_polls, "mae_pp": rating.mae_pp, "house_effects": rating.house_effects, "weight_multiplier": rating.weight_multiplier, "detail": rating.detail} if rating else None}) return sorted(out, key=lambda x: -(x["polls_in_db"])) class WhatIfRequest(BaseModel): national_pp: dict[str, float] = Field(default_factory=dict) regional_pp: dict[str, dict[str, float]] = Field(default_factory=dict) turnout_mult: dict[str, float] = Field(default_factory=dict) poll_error_pp: dict[str, float] = Field(default_factory=dict) n_sims: int = Field(8000, ge=1000, le=40000) @router.post("/whatif") def whatif(req: WhatIfRequest, db: Session = Depends(get_db)): for d in (req.national_pp, req.poll_error_pp): for k, v in d.items(): if abs(v) > 20: raise HTTPException(422, f"Choc trop grand pour {k} (max ±20 pp)") run = _latest_run(db) result_ref = run.national.get("baseline_national") inp = _district_inputs(db, result_ref) inp.x_mean = np.array(run.national["x_forecast"]) inp.P = np.array(run.national["P_forecast"]) scenario = {"national_pp": req.national_pp, "regional_pp": req.regional_pp, "turnout_mult": req.turnout_mult, "poll_error_pp": req.poll_error_pp} sim = SIM.run_simulation(inp, n_sims=req.n_sims, scenario=scenario) return {"base_run_id": run.id, "scenario": scenario, "n_sims": sim.n_sims, "seats": sim.seats, "national_vote": sim.national_vote, "summary": sim.seat_matrix_summary, "districts": sim.districts} @router.get("/backtest") def backtest_report(): f = DATA_DIR / "backtest_report.json" if not f.exists(): raise HTTPException(404, "Backtest non exécuté") return json.loads(f.read_text()) @router.get("/sentiment") def sentiment(db: Session = Depends(get_db), days: int = Query(30, le=120)): cutoff = datetime.now(timezone.utc) - timedelta(days=days) docs = (db.query(Mo.SentimentDocument) .filter(Mo.SentimentDocument.fetched_at >= cutoff).all()) per_party: dict[str, dict] = {} daily: dict[str, dict[str, list]] = {} for doc in docs: d = (doc.published or doc.fetched_at).date().isoformat() for s in doc.scores: pp = per_party.setdefault(s.entity, {"volume": 0, "sum": 0.0, "stances": {"pro": 0, "anti": 0, "neutre": 0, "ambigu": 0}}) pp["volume"] += 1 pp["sum"] += s.sentiment pp["stances"][s.stance] = pp["stances"].get(s.stance, 0) + 1 daily.setdefault(s.entity, {}).setdefault(d, []).append(s.sentiment) now = datetime.now(timezone.utc) def _window(entity, hours): vals = [s.sentiment for doc in docs for s in doc.scores if s.entity == entity and (doc.published or doc.fetched_at) and (now - (doc.published or doc.fetched_at).replace(tzinfo=timezone.utc) ).total_seconds() < hours * 3600] return {"volume": len(vals), "moyenne": round(float(np.mean(vals)), 3) if vals else None} out = {} for party, pp in per_party.items(): out[party] = { "volume": pp["volume"], "sentiment_moyen": round(pp["sum"] / pp["volume"], 3), "stances": pp["stances"], "h24": _window(party, 24), "j7": _window(party, 168), "timeline": [{"date": d, "sentiment": round(float(np.mean(v)), 3), "volume": len(v)} for d, v in sorted(daily.get(party, {}).items())], } n_docs = len(docs) return {"note": ("Signal bruité — n'agit sur le forecast que via l'ajustement " f"médias borné (±{settings.media_nudge_pp_max} pp max par parti)."), "documents": n_docs, "parties": out} @router.get("/momentum") def momentum(db: Session = Depends(get_db)): """Indicateur maison (auxiliaire) : tendance sondages + sentiment + volume.""" run = _latest_run(db) series = run.national["trend_series"] sent = sentiment(db=db, days=14) out = {} for p in [x for x in settings.parties if x != "AUT"]: means = series["mean"][p] d14 = means[-1] - means[max(0, len(means) - 15)] s = sent["parties"].get(p, {}) s7 = (s.get("j7") or {}).get("moyenne") or 0.0 score = float(np.clip(d14 * 12 + s7 * 25, -100, 100)) out[p] = {"score": round(score, 1), "d_vote_14j_pp": round(d14, 2), "sentiment_7j": s7, "volume_7j": (s.get("j7") or {}).get("volume", 0)} return {"note": "Indicateur auxiliaire — n'est PAS une probabilité électorale.", "momentum": out} @router.get("/events") def events(db: Session = Depends(get_db)): rows = db.query(Mo.NewsEvent).order_by(Mo.NewsEvent.event_date.asc()).all() return [{"date": e.event_date.isoformat(), "title": e.title, "kind": e.kind, "description": e.description, "parties": e.parties, "importance": e.importance, "detected_by": e.detected_by} for e in rows] @router.get("/news") def news(db: Session = Depends(get_db), limit: int = Query(40, le=200)): """Nouvelles regroupées par grappe (une entrée par nouvelle, pas 50 doublons).""" docs = (db.query(Mo.SentimentDocument) .order_by(Mo.SentimentDocument.fetched_at.desc()).limit(400).all()) clusters: dict[str, dict] = {} for doc in docs: c = clusters.setdefault(doc.cluster_key or doc.url, { "title": doc.title, "url": doc.url, "source": doc.source, "published": (doc.published or doc.fetched_at).isoformat(), "n_articles": 0, "parties": {}}) c["n_articles"] += 1 for s in doc.scores: c["parties"][s.entity] = {"sentiment": s.sentiment, "stance": s.stance} return list(clusters.values())[:limit] class AskRequest(BaseModel): question: str = Field(min_length=3, max_length=500) @router.post("/ask") def ask(req: AskRequest, db: Session = Depends(get_db)): facts = gather_facts(db) return compose_answer(req.question, facts) # --- API v3 (« 127 ») : alias stables des ressources v3 — la v1 (/api/*) # reste inchangée (compatibilité §36). -------------------------------------- router_v3 = APIRouter(prefix="/api/v3") router_v3.get("/forecast")(forecast_latest) router_v3.get("/ridings")(districts) router_v3.get("/social")(social) router_v3.get("/battlegrounds")(battlegrounds) router_v3.get("/events")(events) router_v3.get("/beyond")(beyond) router_v3.get("/calibration")(backtest_report) router_v3.get("/ablation")(ablation_report) router_v3.get("/replay")(replay_report) router_v3.get("/pollster-error")(pollster_error_report)