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"""API publique — lecture du forecast, sondages, circonscriptions, sentiment."""6from __future__ import annotations78import json9from datetime import date, datetime, timedelta, timezone1011import numpy as np12from fastapi import APIRouter, Depends, HTTPException, Query13from pydantic import BaseModel, Field14from sqlalchemy.orm import Session1516from ..config import DATA_DIR, settings17from ..db import get_db18from .. import models as Mo19from ..modeling import simulate as SIM20from ..modeling.explain import compose_answer, gather_facts21from ..modeling.house_effects import PollsterProfile, adjust_shares, profile_for22from ..modeling.weights import display_weight23from ..pipeline import _district_inputs2425router = APIRouter(prefix="/api")262728def _latest_run(db: Session) -> Mo.ForecastRun:29 run = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False))30 .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first())31 if run is None:32 raise HTTPException(503, "Aucun forecast disponible — lancez le pipeline.")33 return run343536@router.get("/meta")37def meta(db: Session = Depends(get_db)):38 target = db.query(Mo.Election).filter_by(is_target=True).first()39 parties = db.query(Mo.Party).all()40 last = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False))41 .order_by(Mo.ForecastRun.run_at.desc()).first())42 return {43 "app": settings.app_name,44 "election": {"name": target.name if target else settings.election_name,45 "date": settings.election_date.isoformat(),46 "total_seats": settings.total_seats,47 "majority_seats": settings.majority_seats,48 "days_remaining": (settings.election_date - date.today()).days},49 "model_version": settings.model_version,50 "last_update": last.run_at.isoformat() if last else None,51 "parties": {p.code: {"name": p.name, "leader": p.leader, "color": p.color}52 for p in parties},53 }545556@router.get("/forecast/latest")57def forecast_latest(db: Session = Depends(get_db)):58 run = _latest_run(db)59 return {"run_id": run.id, "as_of": run.as_of.isoformat(),60 "run_at": run.run_at.isoformat(), "model_version": run.model_version,61 "n_polls_used": run.n_polls_used, "n_simulations": run.n_simulations,62 "national": run.national, "seats": run.seats, "diagnostics": run.diagnostics}636465@router.get("/forecast/history")66def forecast_history(db: Session = Depends(get_db)):67 """Évolution des probabilités et votes à travers les runs (Forecast Explorer)."""68 runs = (db.query(Mo.ForecastRun).filter(Mo.ForecastRun.is_backtest.is_(False))69 .order_by(Mo.ForecastRun.as_of.asc(), Mo.ForecastRun.id.asc()).all())70 seen: dict[str, Mo.ForecastRun] = {}71 for r in runs:72 seen[r.as_of.isoformat()] = r # dernier run de chaque journée73 out = []74 for iso, r in sorted(seen.items()):75 out.append({76 "as_of": iso, "run_id": r.id,77 "prob_most": {p: r.seats["per_party"][p]["prob_most"] for p in settings.parties},78 "prob_majority": {p: r.seats["per_party"][p]["prob_majority"] for p in settings.parties},79 "seats_mean": {p: r.seats["per_party"][p]["mean"] for p in settings.parties},80 "vote_mean": {p: r.national["forecast"][p]["mean"] for p in settings.parties},81 })82 return out838485@router.get("/forecast/run/{run_id}")86def forecast_run(run_id: int, db: Session = Depends(get_db)):87 run = db.get(Mo.ForecastRun, run_id)88 if run is None:89 raise HTTPException(404, "Run introuvable")90 return {"run_id": run.id, "as_of": run.as_of.isoformat(), "label": run.label,91 "is_backtest": run.is_backtest, "national": run.national,92 "seats": run.seats, "diagnostics": run.diagnostics}939495@router.get("/districts")96def districts(db: Session = Depends(get_db)):97 run = _latest_run(db)98 static = {d.name: d for d in db.query(Mo.District).all()}99 out = []100 for fr in run.district_results:101 d = static.get(fr.district_name)102 out.append({103 **fr.detail,104 "incumbent_party": d.incumbent_party if d else None,105 "incumbent_running": d.incumbent_running if d else None,106 "baseline_2022": d.baseline_shares if d else None,107 "turnout_2022": d.turnout_2022 if d else None,108 "is_new_2026": d.is_new_2026 if d else False,109 })110 return {"as_of": run.as_of.isoformat(), "run_id": run.id, "districts": out}111112113@router.get("/districts/{name}")114def district_detail(name: str, db: Session = Depends(get_db)):115 d = db.query(Mo.District).filter(Mo.District.name == name).first()116 if d is None:117 raise HTTPException(404, "Circonscription introuvable")118 run = _latest_run(db)119 fr = next((x for x in run.district_results if x.district_name == name), None)120 hist = (db.query(Mo.HistoricalDistrictResult)121 .filter_by(district_name=name).all())122 candidates = db.query(Mo.Candidate).filter_by(district_id=d.id).all()123 return {"name": d.name, "region": d.region, "baseline_2022": d.baseline_shares,124 "baseline_source": d.baseline_source, "incumbent_party": d.incumbent_party,125 "incumbent_running": d.incumbent_running, "turnout_2022": d.turnout_2022,126 "is_new_2026": d.is_new_2026,127 "forecast": fr.detail if fr else None,128 "candidates": [{"name": c.name, "party": c.party, "party_full": c.party_full,129 "is_incumbent": c.is_incumbent} for c in candidates],130 "history": [{"party": h.party, "pct": h.pct} for h in hist]}131132133@router.get("/geo")134def geo():135 """Carte électorale officielle 2026 (GeoJSON DGEQ, propriétés minimales)."""136 f = DATA_DIR / "carte_2026.geojson"137 if not f.exists():138 raise HTTPException(404, "Carte non téléchargée — lancez le pipeline.")139 from fastapi.responses import FileResponse140 return FileResponse(f, media_type="application/geo+json",141 headers={"Cache-Control": "public, max-age=86400"})142143144@router.get("/markets")145def markets(db: Session = Depends(get_db)):146 """Marchés prédictifs (signal auxiliaire) + comparaison au modèle."""147 from ..ingest.markets import fetch_winner_market148 data = fetch_winner_market()149 if data is None:150 raise HTTPException(503, "Marchés indisponibles")151 try:152 run = _latest_run(db)153 data["model_prob_most_seats"] = {154 p: run.seats["per_party"][p]["prob_most"]155 for p in settings.parties if p in data["implied_prob_most_seats"]}156 except HTTPException:157 pass158 return data159160161@router.get("/beyond")162def beyond(db: Session = Depends(get_db)):163 """v2 « Au-delà des sondages » : décomposition du forecast par couche164 (sondages / partielles / fondamentaux / médias / marchés), prior de165 fondamentaux, satisfaction gouvernementale, poids selon l'horizon."""166 run = _latest_run(db)167 from ..modeling.fundamentals import blend_weight168 days = run.diagnostics.get("days_to_election", 0)169 schedule = [{"days": d, "weight": round(blend_weight(d), 4)}170 for d in [540, 450, 365, 270, 180, 120, 90, 60, 30, 14, 7, 1, 0]]171 n_bye = run.diagnostics.get("n_byelections", 0)172 return {"run_id": run.id, "as_of": run.as_of.isoformat(),173 "model_version": run.model_version,174 "days_to_election": days,175 "n_polls": run.n_polls_used - n_bye, "n_byelections": n_bye,176 "beyond": run.national.get("beyond") or {},177 "fundamentals_weight_schedule": schedule}178179180@router.get("/byelections")181def byelections(db: Session = Depends(get_db)):182 """Partielles 2022-2026 : votes réels + observation nationale implicite."""183 from ..ingest.byelections import pseudo_polls184 pseudo = {p["pollster"]: p for p in pseudo_polls(db)}185 out = []186 for b in (db.query(Mo.ByElection).order_by(Mo.ByElection.held_on.asc()).all()):187 ps = pseudo.get(f"Vote réel — {b.district_name}")188 out.append({"district": b.district_name, "held_on": b.held_on.isoformat(),189 "result": b.result, "turnout": b.turnout, "winner": b.winner,190 "previous_winner": b.previous_winner, "verified": b.verified,191 "used_in_model": b.used_in_model, "source_url": b.source_url,192 "notes": b.notes,193 "implied_national": ps["shares"] if ps else None,194 "equivalent_n": settings.byelection_equivalent_n,195 "swing_shrink": settings.byelection_swing_shrink})196 return out197198199@router.get("/signals")200def signals(db: Session = Depends(get_db), kind: str | None = None,201 limit: int = Query(60, le=300)):202 """Veille web continue (Firecrawl) : radar sondages, satisfaction, presse."""203 q = db.query(Mo.WebSignal).order_by(Mo.WebSignal.detected_at.desc())204 if kind:205 q = q.filter(Mo.WebSignal.kind == kind)206 rows = q.limit(limit).all()207 sat = (db.query(Mo.Indicator).filter_by(name="gov_satisfaction")208 .order_by(Mo.Indicator.as_of.desc()).first())209 return {210 "enabled": bool(settings.firecrawl_api_key) and settings.firecrawl_enabled,211 "satisfaction": ({"value": sat.value, "as_of": sat.as_of.isoformat(),212 "source": sat.source, "source_url": sat.source_url,213 "method": sat.method} if sat else214 {"value": settings.gov_satisfaction_fallback,215 "method": "fallback-config"}),216 "signals": [{"detected_at": s.detected_at.isoformat(), "kind": s.kind,217 "title": s.title, "url": s.url, "snippet": s.snippet,218 "pollster": s.pollster, "status": s.status, "extra": s.extra}219 for s in rows]}220221222@router.get("/social")223def social(db: Session = Depends(get_db), days: int = Query(14, le=60)):224 """Pouls social continu (YouTube/Reddit/Mastodon/Lemmy + engagement des225 comptes officiels via acteur Apify maison) — poids nul dans le forecast."""226 from ..ingest.social_pulse import pulse_summary227 from ..modeling.signals.social import pulse_index228 out = pulse_summary(db, days=days)229 out["pulse_index"] = pulse_index(db)230 return out231232233@router.get("/battlegrounds")234def battlegrounds(db: Session = Depends(get_db)):235 """Circonscriptions pivots (v3 §24) : P(fait basculer la majorité) par la236 méthode des simulations + champs de bataille (chaudement disputés)."""237 run = _latest_run(db)238 tips = (run.seats or {}).get("battlegrounds")239 contested = sorted((fr for fr in run.district_results240 if fr.category == "Chaudement disputé"),241 key=lambda fr: -max(fr.detail["win_probs"].values()))242 # Course à 64 : pour chaque parti, circonscriptions classées de la plus243 # sûre à la plus décisive; la 64ᵉ est le « siège de la majorité ».244 paths = {}245 for party in [p for p in settings.parties if p != "AUT"]:246 ranked = sorted(run.district_results,247 key=lambda fr: -fr.detail["win_probs"].get(party, 0.0))248 rows = []249 for i, fr in enumerate(ranked[:80], start=1):250 wp = fr.detail["win_probs"].get(party, 0.0)251 exp = fr.detail.get("expected") or {}252 top2 = sorted(exp.values(), reverse=True)[:2]253 rows.append({"rank": i, "district": fr.district_name,254 "win_prob": wp,255 "margin_pp": round(top2[0] - top2[1], 1) if len(top2) == 2 else None,256 "favorite": fr.favorite,257 "is_majority_seat": i == settings.majority_seats})258 seat64 = rows[settings.majority_seats - 1] if len(rows) >= settings.majority_seats else None259 paths[party] = {"seats_ge_50": sum(1 for r in rows if r["win_prob"] >= 0.5),260 "majority_seat": seat64,261 "ladder": rows}262 return {"run_id": run.id, "as_of": run.as_of.isoformat(),263 "tipping": tips,264 "paths_to_64": paths,265 "contested": [{"district": fr.district_name,266 "favorite": fr.favorite,267 "win_probs": fr.detail["win_probs"],268 "region": fr.detail.get("region")}269 for fr in contested]}270271272@router.get("/ablation")273def ablation_report():274 """Dernier rapport d'ablation (v3 §22) — quelle couche apporte quoi."""275 f = DATA_DIR / "ablation_report.json"276 if not f.exists():277 raise HTTPException(404, "Ablation non exécutée — POST /api/admin/ablation")278 return json.loads(f.read_text())279280281@router.get("/demographics/{name}")282def demographics(name: str):283 """Démographie officielle de la circonscription (Recensement 2021 sur les284 limites 2026, classeur Élections Québec) + circonscriptions semblables."""285 from ..ingest.eq_socioeconomic import POP_DIR, slug_of286 f = POP_DIR / "portraits" / f"{slug_of(name)}.json"287 if not f.exists():288 raise HTTPException(404, "Démographie non disponible")289 data = json.loads(f.read_text())290 simf = POP_DIR / "riding_similarity.json"291 if simf.exists():292 data["similar"] = json.loads(simf.read_text()).get(data["district"], [])[:6]293 return data294295296@router.get("/election-night")297def election_night():298 """État du modèle du soir d'élection (dormant avant le 5 octobre 2026)."""299 from ..modeling.election_night import latest_state300 state = latest_state()301 if state is None:302 raise HTTPException(404, "Le soir d'élection n'a pas commencé — le "303 "modèle s'activera avec les premiers résultats "304 "officiels du DGEQ le 5 octobre 2026.")305 return state306307308@router.get("/replay")309def replay_report():310 """Replay historique multi-élections (2007-2022, LOEO strict, §52-54)."""311 f = DATA_DIR / "replay_report.json"312 if not f.exists():313 raise HTTPException(404, "Replay non exécuté — POST /api/admin/replay")314 return json.loads(f.read_text())315316317@router.get("/pollster-error")318def pollster_error_report():319 """Modèle d'erreur des sondeurs (§6-10) : erreurs d'industrie par élection,320 σ_industrie estimé LOEO, house effects hiérarchiques."""321 from ..modeling.national.pollster_error import report322 return report()323324325@router.get("/attention")326def attention(db: Session = Depends(get_db)):327 """Attention Wikipédia : parts, tendances, turbulence (signal non directionnel)."""328 from ..ingest.wiki_attention import signal329 return signal(db)330331332@router.get("/synthetic")333def synthetic(db: Session = Depends(get_db)):334 """Dernier sondage synthétique LLM — EXPÉRIMENTAL, poids nul dans le forecast."""335 from ..modeling.synthetic_poll import latest336 data = latest(db)337 if data is None:338 raise HTTPException(404, "Aucun sondage synthétique généré pour l'instant")339 try:340 run = _latest_run(db)341 data["forecast_comparison"] = {342 p: {"synthetique": data["shares"].get(p),343 "forecast": run.national["forecast"][p]["mean"]}344 for p in settings.parties if data.get("shares")}345 except HTTPException:346 pass347 return data348349350@router.get("/today")351def today(db: Session = Depends(get_db)):352 """« Le point du jour » : forecast daté + ce qui a changé depuis la veille."""353 run = _latest_run(db)354 # dernier run d'une journée antérieure (comparaison « depuis hier »)355 prev = (db.query(Mo.ForecastRun)356 .filter(Mo.ForecastRun.is_backtest.is_(False),357 Mo.ForecastRun.as_of < run.as_of)358 .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first())359360 def probs(r):361 ens = (r.seats.get("ensemble") or {}).get("blended") if r.seats else None362 return {p: (ens or {}).get(p, r.seats["per_party"][p]["prob_most"])363 for p in settings.parties}364365 cur_p, cur_v = probs(run), {p: run.national["forecast"][p]["mean"]366 for p in settings.parties}367 cur_s = {p: run.seats["per_party"][p]["mean"] for p in settings.parties}368 delta = None369 if prev:370 pv = {p: prev.national["forecast"][p]["mean"] for p in settings.parties}371 pp = probs(prev)372 ps = {p: prev.seats["per_party"][p]["mean"] for p in settings.parties}373 delta = {"since": prev.as_of.isoformat(),374 "prob_most": {p: round(cur_p[p] - pp[p], 4) for p in settings.parties},375 "vote": {p: round(cur_v[p] - pv[p], 2) for p in settings.parties},376 "seats": {p: round(cur_s[p] - ps[p], 1) for p in settings.parties}}377378 # nouveautés des dernières 24 h379 day_ago = datetime.now(timezone.utc) - timedelta(hours=24)380 new_polls = (db.query(Mo.Poll).filter(Mo.Poll.accessed_at >= day_ago)381 .order_by(Mo.Poll.field_end.desc()).limit(10).all())382 new_signals = (db.query(Mo.WebSignal).filter(Mo.WebSignal.detected_at >= day_ago)383 .order_by(Mo.WebSignal.detected_at.desc()).limit(12).all())384 upcoming = (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date >= date.today())385 .order_by(Mo.NewsEvent.event_date.asc()).limit(5).all())386387 target = db.query(Mo.Election).filter_by(is_target=True).first()388 B = run.national.get("beyond") or {}389 return {390 "date": date.today().isoformat(),391 "as_of": run.as_of.isoformat(), "run_id": run.id,392 "run_at": run.run_at.isoformat(), "model_version": run.model_version,393 "days_to_election": (target.election_date - date.today()).days if target else None,394 "prob_most": cur_p, "vote": cur_v,395 "seats": {p: {"mean": cur_s[p],396 "p05": run.seats["per_party"][p]["p05"],397 "p95": run.seats["per_party"][p]["p95"],398 "prob_majority": run.seats["per_party"][p]["prob_majority"]}399 for p in settings.parties},400 "prob_no_majority": run.seats["summary"]["prob_no_majority"],401 "delta": delta,402 "beyond": {"web_attention": B.get("web_attention"),403 "media_adjustment": B.get("media_adjustment"),404 "market_ensemble": B.get("market_ensemble"),405 "fundamentals": {"satisfaction": (B.get("fundamentals") or {}).get("satisfaction"),406 "blend": (B.get("fundamentals") or {}).get("blend")},407 "n_byelections": len(B.get("byelections_used") or [])},408 "new_polls_24h": [{"pollster": p.pollster.name,409 "field_end": p.field_end.isoformat(),410 "sample_size": p.sample_size,411 "shares": {r.party: r.normalized_value for r in p.results}}412 for p in new_polls],413 "new_signals_24h": [{"kind": s.kind, "title": s.title, "url": s.url,414 "pollster": s.pollster} for s in new_signals],415 "upcoming_events": [{"date": e.event_date.isoformat(), "title": e.title,416 "kind": e.kind} for e in upcoming],417 }418419420@router.get("/polls")421def polls(db: Session = Depends(get_db),422 date_from: date | None = None, date_to: date | None = None,423 pollster: str | None = None, election: str = "2026",424 limit: int = Query(500, le=2000)):425 el = (db.query(Mo.Election)426 .filter(Mo.Election.name.contains(election)).first())427 q = db.query(Mo.Poll).filter(Mo.Poll.election_id == el.id) if el else db.query(Mo.Poll)428 if date_from:429 q = q.filter(Mo.Poll.field_end >= date_from)430 if date_to:431 q = q.filter(Mo.Poll.field_end <= date_to)432 if pollster:433 q = q.join(Mo.Pollster).filter(Mo.Pollster.name == pollster)434 rows = q.order_by(Mo.Poll.field_end.desc()).limit(limit).all()435436 ratings = {r.pollster.name: r for r in db.query(Mo.PollsterRating).all()}437 as_of = date.today()438 out, max_w = [], 1e-9439 for poll in rows:440 rating = ratings.get(poll.pollster.name)441 prof = PollsterProfile(442 name=poll.pollster.name,443 mae_pp=rating.mae_pp if rating else None,444 house_effects=rating.house_effects if rating else {},445 weight_multiplier=rating.weight_multiplier if rating else 0.9)446 shares = {r.party: r.normalized_value for r in poll.results}447 adj = adjust_shares(shares, prof) if shares else {}448 w = display_weight(poll.field_end, as_of, poll.sample_size, poll.mode,449 prof.weight_multiplier)450 max_w = max(max_w, w)451 out.append({452 "id": poll.id, "pollster": poll.pollster.name, "sponsor": poll.sponsor,453 "field_start": poll.field_start.isoformat() if poll.field_start else None,454 "field_end": poll.field_end.isoformat(),455 "sample_size": poll.sample_size, "moe": poll.moe, "mode": poll.mode,456 "population": poll.population, "source_url": poll.source_url,457 "source_name": poll.source_name, "excluded": poll.excluded,458 "raw": {r.party: r.raw_value for r in poll.results},459 "normalized": shares,460 "adjusted": {k: round(v, 1) for k, v in adj.items()},461 "weight": w,462 })463 for r in out:464 r["weight"] = round(r["weight"] / max_w, 4)465 return out466467468@router.get("/pollsters")469def pollsters(db: Session = Depends(get_db)):470 out = []471 for p in db.query(Mo.Pollster).all():472 rating = (db.query(Mo.PollsterRating).filter_by(pollster_id=p.id)473 .order_by(Mo.PollsterRating.computed_at.desc()).first())474 n = db.query(Mo.Poll).filter_by(pollster_id=p.id).count()475 out.append({"name": p.name, "polls_in_db": n,476 "rating": {"n_final_polls": rating.n_polls, "mae_pp": rating.mae_pp,477 "house_effects": rating.house_effects,478 "weight_multiplier": rating.weight_multiplier,479 "detail": rating.detail} if rating else None})480 return sorted(out, key=lambda x: -(x["polls_in_db"]))481482483class WhatIfRequest(BaseModel):484 national_pp: dict[str, float] = Field(default_factory=dict)485 regional_pp: dict[str, dict[str, float]] = Field(default_factory=dict)486 turnout_mult: dict[str, float] = Field(default_factory=dict)487 poll_error_pp: dict[str, float] = Field(default_factory=dict)488 n_sims: int = Field(8000, ge=1000, le=40000)489490491@router.post("/whatif")492def whatif(req: WhatIfRequest, db: Session = Depends(get_db)):493 for d in (req.national_pp, req.poll_error_pp):494 for k, v in d.items():495 if abs(v) > 20:496 raise HTTPException(422, f"Choc trop grand pour {k} (max ±20 pp)")497 run = _latest_run(db)498 result_ref = run.national.get("baseline_national")499 inp = _district_inputs(db, result_ref)500 inp.x_mean = np.array(run.national["x_forecast"])501 inp.P = np.array(run.national["P_forecast"])502 scenario = {"national_pp": req.national_pp, "regional_pp": req.regional_pp,503 "turnout_mult": req.turnout_mult, "poll_error_pp": req.poll_error_pp}504 sim = SIM.run_simulation(inp, n_sims=req.n_sims, scenario=scenario)505 return {"base_run_id": run.id, "scenario": scenario, "n_sims": sim.n_sims,506 "seats": sim.seats, "national_vote": sim.national_vote,507 "summary": sim.seat_matrix_summary,508 "districts": sim.districts}509510511@router.get("/backtest")512def backtest_report():513 f = DATA_DIR / "backtest_report.json"514 if not f.exists():515 raise HTTPException(404, "Backtest non exécuté")516 return json.loads(f.read_text())517518519@router.get("/sentiment")520def sentiment(db: Session = Depends(get_db), days: int = Query(30, le=120)):521 cutoff = datetime.now(timezone.utc) - timedelta(days=days)522 docs = (db.query(Mo.SentimentDocument)523 .filter(Mo.SentimentDocument.fetched_at >= cutoff).all())524 per_party: dict[str, dict] = {}525 daily: dict[str, dict[str, list]] = {}526 for doc in docs:527 d = (doc.published or doc.fetched_at).date().isoformat()528 for s in doc.scores:529 pp = per_party.setdefault(s.entity, {"volume": 0, "sum": 0.0,530 "stances": {"pro": 0, "anti": 0,531 "neutre": 0, "ambigu": 0}})532 pp["volume"] += 1533 pp["sum"] += s.sentiment534 pp["stances"][s.stance] = pp["stances"].get(s.stance, 0) + 1535 daily.setdefault(s.entity, {}).setdefault(d, []).append(s.sentiment)536537 now = datetime.now(timezone.utc)538 def _window(entity, hours):539 vals = [s.sentiment for doc in docs for s in doc.scores540 if s.entity == entity and (doc.published or doc.fetched_at)541 and (now - (doc.published or doc.fetched_at).replace(tzinfo=timezone.utc)542 ).total_seconds() < hours * 3600]543 return {"volume": len(vals),544 "moyenne": round(float(np.mean(vals)), 3) if vals else None}545546 out = {}547 for party, pp in per_party.items():548 out[party] = {549 "volume": pp["volume"],550 "sentiment_moyen": round(pp["sum"] / pp["volume"], 3),551 "stances": pp["stances"],552 "h24": _window(party, 24), "j7": _window(party, 168),553 "timeline": [{"date": d, "sentiment": round(float(np.mean(v)), 3), "volume": len(v)}554 for d, v in sorted(daily.get(party, {}).items())],555 }556 n_docs = len(docs)557 return {"note": ("Signal bruité — n'agit sur le forecast que via l'ajustement "558 f"médias borné (±{settings.media_nudge_pp_max} pp max par parti)."),559 "documents": n_docs, "parties": out}560561562@router.get("/momentum")563def momentum(db: Session = Depends(get_db)):564 """Indicateur maison (auxiliaire) : tendance sondages + sentiment + volume."""565 run = _latest_run(db)566 series = run.national["trend_series"]567 sent = sentiment(db=db, days=14)568 out = {}569 for p in [x for x in settings.parties if x != "AUT"]:570 means = series["mean"][p]571 d14 = means[-1] - means[max(0, len(means) - 15)]572 s = sent["parties"].get(p, {})573 s7 = (s.get("j7") or {}).get("moyenne") or 0.0574 score = float(np.clip(d14 * 12 + s7 * 25, -100, 100))575 out[p] = {"score": round(score, 1), "d_vote_14j_pp": round(d14, 2),576 "sentiment_7j": s7, "volume_7j": (s.get("j7") or {}).get("volume", 0)}577 return {"note": "Indicateur auxiliaire — n'est PAS une probabilité électorale.",578 "momentum": out}579580581@router.get("/events")582def events(db: Session = Depends(get_db)):583 rows = db.query(Mo.NewsEvent).order_by(Mo.NewsEvent.event_date.asc()).all()584 return [{"date": e.event_date.isoformat(), "title": e.title, "kind": e.kind,585 "description": e.description, "parties": e.parties,586 "importance": e.importance, "detected_by": e.detected_by} for e in rows]587588589@router.get("/news")590def news(db: Session = Depends(get_db), limit: int = Query(40, le=200)):591 """Nouvelles regroupées par grappe (une entrée par nouvelle, pas 50 doublons)."""592 docs = (db.query(Mo.SentimentDocument)593 .order_by(Mo.SentimentDocument.fetched_at.desc()).limit(400).all())594 clusters: dict[str, dict] = {}595 for doc in docs:596 c = clusters.setdefault(doc.cluster_key or doc.url, {597 "title": doc.title, "url": doc.url, "source": doc.source,598 "published": (doc.published or doc.fetched_at).isoformat(),599 "n_articles": 0, "parties": {}})600 c["n_articles"] += 1601 for s in doc.scores:602 c["parties"][s.entity] = {"sentiment": s.sentiment, "stance": s.stance}603 return list(clusters.values())[:limit]604605606class AskRequest(BaseModel):607 question: str = Field(min_length=3, max_length=500)608609610@router.post("/ask")611def ask(req: AskRequest, db: Session = Depends(get_db)):612 facts = gather_facts(db)613 return compose_answer(req.question, facts)614615616# --- API v3 (« 127 ») : alias stables des ressources v3 — la v1 (/api/*)617# reste inchangée (compatibilité §36). --------------------------------------618router_v3 = APIRouter(prefix="/api/v3")619router_v3.get("/forecast")(forecast_latest)620router_v3.get("/ridings")(districts)621router_v3.get("/social")(social)622router_v3.get("/battlegrounds")(battlegrounds)623router_v3.get("/events")(events)624router_v3.get("/beyond")(beyond)625router_v3.get("/calibration")(backtest_report)626router_v3.get("/ablation")(ablation_report)627router_v3.get("/replay")(replay_report)628router_v3.get("/pollster-error")(pollster_error_report)629