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%
28.5 KB · 629 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"""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