diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f731d9c --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Connexion PostgreSQL utilisée par l'ETL (etl/database.py). +# Ces valeurs correspondent à l'instance locale définie dans docker-compose.yml. +# Copier ce fichier en `.env` : `cp .env.example .env` +PG_USER=postgres +PG_PWD=postgres +PG_DB=ipolitics +PG_HOST=localhost +PG_PORT=5432 + +# Mettre à True pour logguer toutes les requêtes SQL émises par SQLAlchemy. +PG_ECHO=False diff --git a/.gitignore b/.gitignore index 46e1244..21f684d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Project ignore data/* +analysis/output/ # From https://github.com/github/gitignore/blob/main/Python.gitignore diff --git a/README.md b/README.md index a661458..bcbb846 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ Voici les différents endpoint: * députés => `https://parlement.tricoteuses.fr/acteurs` * votes => `https://parlement.tricoteuses.fr/scrutins` -L'API propose de nombreux endpoints. [Information sur le chemin d'une loi](https://www.assemblee-nationale.fr/dyn/actualites-accueil-hub/le-parcours-de-la-loi) @@ -35,10 +34,23 @@ L'API propose de nombreux endpoints. - [Installation d'UV](https://docs.astral.sh/uv/) ### Setup + +1. Installer les dépendances : ```bash uv sync ``` +2. Créer le fichier `.env` à partir de l'exemple, puis l'adapter si besoin : +```bash +cp .env.example .env +``` + +3. Démarrer PostgreSQL (instance locale définie dans `docker-compose.yml`, sur le port `5432`) : +```bash +docker compose up -d db +``` +Les valeurs par défaut de `.env.example` correspondent à ce conteneur (`postgres`/`postgres`, base `ipolitics`). + ### Usages #### Exécuter des commandes @@ -112,12 +124,12 @@ Voici une partie du fichier `./data/dossiers.json` Je veux rajouter le champ `chambre` dans la DB et faire en sorte que l'ETL l'ajoute de lui-même. 1. Rajouter le champ dans le modèle -``` -class User(Base): +```python +class Dossier(Base): __tablename__ = "dossiers" uid: Mapped[str] = mapped_column(primary_key=True) - titre: Mapped[str] = mapped_column(String(500)) + titre: Mapped[str] = mapped_column(String(1000)) dataset: Mapped[int] chambre: Mapped[str] = mapped_column(String(5)) # <-------- nouvelle colonne qui porte le même nom que le champ du fichier json ``` @@ -125,3 +137,188 @@ class User(Base): Le champ doit porter le même nom sinon l'ETL ne sera pas capable de le trouver. 2. Exécuter `just all` + +## Objets parlementaires chargés + + +| Table | Endpoint | Contenu | Volume (législature 17) | +|---|---|---|---| +| `acteurs` | `/acteurs` | Députés / sénateurs (référentiel trans-législature) | ~3 100 | +| `organes` | `/organes` | Groupes politiques, commissions, assemblées… | ~5 400 | +| `mandats` | `/mandats` | Jointure acteur ↔ organe (appartenance + dates) | ~25 600 | +| `scrutins` | `/scrutins` | Scrutins publics et résultat agrégé (pour/contre/abstentions) | ~8 200 | +| `groupesVotants` | `/groupesVotants` | Résultat d'un scrutin ventilé par groupe politique | ~121 500 * | +| `documents` | `/documents` | Textes (projets/propositions de loi, rapports…) | ~4 500 | +| `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 | +| `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 | + +\* `groupesVotants` n'expose pas de filtre `legislature` : la table couvre toutes les législatures +(~98 300 lignes se rattachent à un scrutin de la L17, soit 12 groupes pour chacun des 8 192 +scrutins concernés). Se scoper par jointure sur `scrutins`. + +Les jointures se font par les colonnes `…RefUid` (références molles, nullable, indexées). Schéma +entité-relation ci-dessous — les boîtes ne montrent que les colonnes clés (PK + FK + quelques +champs parlants) ; la liste complète est dans les modèles `models/`. + +```mermaid +erDiagram + dossiers { + string uid PK + string titre + string libelleProcedure + string statut + } + documents { + string uid PK + string dossierRefUid FK + string auteurPrincipalUid FK + text titrePrincipal + string classeLibelle + bool texteLoi + string dateDepot + } + amendements { + string uid PK + string acteurRefUid FK + string groupePolitiqueRefUid FK + string dossierRefUid FK + string documentRefUid FK + string scrutinRefUid FK + string numeroLong + string divisionArticleDesignation + text exposeSommaire + string sortAmendement + string dateDepot + } + acteurs { + string uid PK + string groupeParlementaireUid FK + string nom + string prenom + string chambre + bool actif + } + organes { + string uid PK + string codeType + string libelleAbrev + string positionPolitique + } + mandats { + string uid PK + string acteurRefUid FK + string organeRefUid FK + string typeOrgane + string libQualite + string dateDebut + string dateFin + } + scrutins { + string uid PK + string dossierRefUid FK + string documentRefUid FK + string amendementRefUid FK + string dateScrutin + text objet + string code + int pour + int contre + int abstentions + } + groupesVotants { + string uid PK + string scrutinRefUid FK + string organeRefUid FK + string positionMajoritaire + int pour + int contre + int abstentions + } + auteursDocument { + string uid PK + string documentRefUid FK + string acteurRefUid FK + string qualite + } + coSignatairesDocument { + string uid PK + string documentRefUid FK + string acteurRefUid FK + string dateCosignature + } + + dossiers ||--o{ documents : "dossierRefUid" + dossiers ||--o{ amendements : "dossierRefUid" + dossiers ||--o{ scrutins : "dossierRefUid" + documents ||--o{ amendements : "documentRefUid" + documents ||--o{ scrutins : "documentRefUid" + documents ||--o{ auteursDocument : "documentRefUid" + documents ||--o{ coSignatairesDocument : "documentRefUid" + acteurs ||--o{ documents : "auteurPrincipalUid" + acteurs ||--o{ amendements : "acteurRefUid" + acteurs ||--o{ mandats : "acteurRefUid" + acteurs ||--o{ auteursDocument : "acteurRefUid" + acteurs ||--o{ coSignatairesDocument : "acteurRefUid" + organes ||--o{ acteurs : "groupeParlementaireUid" + organes ||--o{ amendements : "groupePolitiqueRefUid" + organes ||--o{ mandats : "organeRefUid" + organes ||--o{ groupesVotants : "organeRefUid" + amendements ||--o{ scrutins : "amendementRefUid" + scrutins ||--o{ amendements : "scrutinRefUid" + scrutins ||--o{ groupesVotants : "scrutinRefUid" +``` + +Le lien **amendement ↔ scrutin** est natif, et dans les deux sens : `scrutins.amendementRefUid` +pointe vers l'amendement tranché par le scrutin, et `amendements.scrutinRefUid` vers le scrutin +qui a tranché l'amendement. Le second est le plus large (~11 600 amendements contre ~6 800), +un même scrutin pouvant trancher plusieurs amendements identiques. La jointure reste clairsemée : +la plupart des amendements sont tranchés à main levée, sans scrutin public. + +# Analyse : détection des mentions de collaboration externe + +Objectif : repérer les amendements dont l'exposé sommaire déclare une collaboration ou une +inspiration avec une **entité externe** (lobby, syndicat, association, entreprise, fédération +professionnelle, ONG…) — formulations du type « travaillé avec… », « en concertation avec… », +« inspiré de… ». + +Le détecteur (`analysis/detect_mentions_regex.py`) fonctionne par expressions régulières : +déterministe, instantané et sans coût, il matche des familles de formulations calibrées sur +le corpus réel, avec des exclusions contextuelles (acteurs publics ou parlementaires, référents +textuels type « proposé par le texte ») pour limiter les faux positifs. + +## Lancer une analyse + +La base doit être alimentée au préalable (table `amendements`, voir les sections ETL ci-dessus). + +```bash +# Tout le corpus, résultats en JSONL uniquement +just detect-mentions-regex + +# + écriture des mentions dans la table amendement_mentions +just detect-mentions-regex --persist + +# Sur un sous-ensemble +just detect-mentions-regex --limit 100 + +# Équivalent sans just : +uv run python -m analysis.detect_mentions_regex --persist +``` + +## Sorties + +- **JSONL brut** : `analysis/output/mentions_regex.jsonl` (une ligne par amendement, dossier + gitignoré), plus un récap console des formulations rencontrées et de leur fréquence. +- **Base** (avec `--persist`) : table `amendement_mentions`, une ligne par mention détectée + (`amendementUid`, `citation`, `formulation`, `modele='regex:v1'`, `createdAt`). L'écriture est + idempotente par amendement et scopée au tag `modele='regex:v1'` : les lignes produites par + d'autres détecteurs (ex. un LLM) ne sont jamais touchées. +- Le repérage regex ne remplit ni `entite`, ni `typeEntite`, ni `externe` : identifier et + qualifier l'entité demande une analyse sémantique (prévue dans une itération ultérieure). + +## Tables d'analyse et rebuild + +`amendement_mentions` est une **table d'analyse** : elle n'est pas listée dans `ETL_TABLES` +(`etl/database.py`) et **survit donc à un `db-rebuild`**, contrairement à `amendements`/`dossiers` +qui sont détruites puis rechargées. Sa colonne `amendementUid` est une référence *molle* vers +`amendements.uid` (pas de `ForeignKey`), afin qu'aucune contrainte ne bloque le drop de la table +ETL. Pour ajouter une nouvelle analyse, créer un modèle sur ce principe (hors `ETL_TABLES`). diff --git a/analysis/__init__.py b/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analysis/detect_mentions_regex.py b/analysis/detect_mentions_regex.py new file mode 100644 index 0000000..0a56e0a --- /dev/null +++ b/analysis/detect_mentions_regex.py @@ -0,0 +1,308 @@ +"""Détection par expressions régulières (repérage seul) des mentions de collaboration. + +On repère dans l'exposé sommaire les tournures de collaboration / inspiration avec un +acteur externe (« travaillé avec… », « en concertation avec… », « inspiré de… »), à +partir des familles de formulations réellement observées sur une partie du corpus. + +Repérage seul : dans la DB on ne remplit que `citation` (la phrase qui matche) et `formulation` +(le libellé canonique de la famille). L'entité et son type sont laissés à NULL. +Les lignes sont taguées `modele='regex:v1'` dans amendement_mentions. + +Usage: + uv run python -m analysis.detect_mentions_regex # tout le corpus, sans écrire en base + uv run python -m analysis.detect_mentions_regex --persist # + écriture dans amendement_mentions + uv run python -m analysis.detect_mentions_regex --limit 50 +""" + +import argparse +import json +import re +from pathlib import Path + +from dotenv import load_dotenv +from sqlalchemy import delete, text +from sqlalchemy.orm import Session + +from etl.database import get_engine +from models.amendement_mention import AmendementMention + +MODELE = "regex:v1" +OUTPUT_DIR = Path("analysis/output") + +# Apostrophe droite ou typographique. +_APO = "['’]" + +# Acteurs publics / internes au Parlement : si l'un d'eux apparaît juste après la +# tournure, la mention n'est pas comptée (collaboration institutionnelle normale, +# pas une influence externe). +PUBLIC_ACTORS = re.compile( + rf"\b(?:gouvernements?|s[ée]nats?|assembl[ée]e\s+nationale|commissions?|missions?" + rf"|rapporteure?s?|s[ée]nateurs?|s[ée]natrices?|d[ée]put[ée]\w*" + rf"|minist(?:res?|ères?)|conseil\s+d{_APO}[ée]tat|cour\s+des\s+comptes" + rf"|pouvoirs\s+publics|premier\s+ministre|l[ée]gislateur)\b", + re.IGNORECASE, +) + +# Référents non-acteurs après « inspiré de » : inspiration d'un texte, d'un mécanisme +# juridique... et non d'un acteur. Vérifié en tout début de fenêtre (pas de nom +# d'acteur attendu ni de capitalisation exigée). +NON_ACTOR_REFERENT = re.compile( + rf"^\s*(?:la\s+|le\s+|les\s+|l{_APO}|une?\s+|celle\s+|ceux\s+)?" + r"(?:lois?|procédures?|rédactions?|directives?|jurisprudences?|dispositifs?" + r"|mécanismes?|modèles?|systèmes?|droits?|articles?|textes?|expérimentations?" + r"|exemples?|recherches?|logiques?|principes?|esprit|pratiques?|méthod\w+" + r"|réglementations?|législations?|régimes?|amendements?|dispositions?)\b", + re.IGNORECASE, +) + +# Référents textuels ou institutionnels après « proposé par », « à la demande de »... : +# le texte de loi lui-même, un rapport, un groupe politique, un rôle administratif — +# pas un acteur externe. +TEXT_REFERENT = re.compile( + rf"^\s*(?:le\s+|la\s+|les\s+|l{_APO}|ce\s+|cet\s+|cette\s+|d[ue]s?\s+" + rf"|de\s+la\s+|de\s+l{_APO})*(?:présente?s?\s+)?" + r"(?:textes?|projets?\s+de\s+loi|propositions?\s+de\s+loi|amendements?|articles?" + r"|rapports?|études?|dispositifs?|rédactions?|alinéas?|lois?|codes?|groupes?" + r"|autorités?|représentants?|agents?|présidents?|responsables?" + r"|fournisseurs?|distributeurs?|cnil)\b", + re.IGNORECASE, +) + +# (formulation canonique, motif, exclure si acteur public ensuite, exclusion supplémentaire). +PATTERNS: list[tuple[str, re.Pattern, bool, re.Pattern | None]] = [ + # participe d'élaboration (+ éventuel « en lien/concertation... ») + « avec » + ( + "travaillé avec", + re.compile( + r"\b(?:travaill(?:é|ée|és|ées)|(?:co-?)?constru(?:it|ite|its|ites)" + r"|(?:co-?)?rédig(?:é|ée|és|ées)|(?:co-?)?écrit(?:e|s|es)?" + r"|élabor(?:é|ée|és|ées)|conçu(?:e|s|es)?|prépar(?:é|ée|és|ées)" + r"|réalis(?:é|ée|és|ées)|bâti(?:e|s|es)?)" + r"(?:\s+(?:en\s+(?:lien|concertation|collaboration|partenariat|coopération)" + r"|conjointement|étroitement))?\s+avec\b", + re.IGNORECASE, + ), + True, + None, + ), + # « en collaboration / concertation / partenariat avec » (sans participe devant) + ( + "en collaboration avec", + re.compile( + r"\ben\s+(?:collaboration|concertation|partenariat|coopération)\s+avec\b", + re.IGNORECASE, + ), + True, + None, + ), + # « avec le concours / l'appui / le soutien / l'aide de » + ( + "avec le concours de", + re.compile( + rf"\bavec\s+(?:le\s+concours|l{_APO}appui|le\s+soutien|l{_APO}aide)\s+d", + re.IGNORECASE, + ), + True, + None, + ), + # « inspiré de / s'inspire de » — seulement si le référent n'est pas un objet + # juridique (loi, article, procédure...) + ( + "inspiré de", + re.compile( + rf"\b(?:inspir(?:é|ée|és|ées)|s{_APO}inspir\w+)" + rf"(?:\s+\w+ment)?\s+(?:de\s+|d{_APO}|du\s+|des\s+|par\s+)", + re.IGNORECASE, + ), + True, + NON_ACTOR_REFERENT, + ), + # « sur proposition / suggestion / recommandation de » + ( + "sur proposition de", + re.compile( + r"\bsur\s+(?:proposition|suggestion|recommandation)s?\s+d", re.IGNORECASE + ), + True, + TEXT_REFERENT, + ), + # « issu d'une proposition / des travaux de » + ( + "issu d'une proposition de", + re.compile( + rf"\biss\w+\s+(?:d{_APO}une\s+proposition|des\s+travaux|de\s+propositions)\b", + re.IGNORECASE, + ), + True, + None, + ), + # « reprend … la demande / recommandation / proposition de » + ( + "reprend la demande de", + re.compile( + r"\breprend\w*\b[^.]{0,30}?\b(?:proposition|recommandation|demande)s?\s+d", + re.IGNORECASE, + ), + True, + TEXT_REFERENT, + ), + # « recommandation(s) / préconisation(s) de X » ou « formulées par X » + ( + "recommandation de", + re.compile( + rf"\b(?:recommandation|préconisation)s?\s+(?:de\s+|du\s+|des\s+|d{_APO}" + r"|formulées?\s+par\s+)", + re.IGNORECASE, + ), + True, + TEXT_REFERENT, + ), + # « proposé / validé / demandé / formulé / suggéré / préconisé par X » + ( + "proposé par", + re.compile( + r"\b(?:proposé|validé|recommandé|préconisé|suggéré|demandé" + r"|formulé)(?:e|s|es)?\s+par\b", + re.IGNORECASE, + ), + True, + TEXT_REFERENT, + ), + # « à la demande de X » + ( + "à la demande de", + re.compile(rf"\bà\s+la\s+demande\s+d(?:e\s+|u\s+|es\s+|{_APO})", re.IGNORECASE), + True, + TEXT_REFERENT, + ), +] + +_BOUNDARIES = ".!?\n" + +# Taille de la fenêtre inspectée après la tournure pour les exclusions contextuelles. +_WINDOW = 60 + + +def sentence_around(txt: str, start: int, end: int) -> str: + """Retourne la phrase englobant le match [start:end] (bornes = . ! ? ou saut de ligne).""" + left = max((txt.rfind(b, 0, start) for b in _BOUNDARIES), default=-1) + rights = [pos for b in _BOUNDARIES if (pos := txt.find(b, end)) != -1] + right = min(rights) if rights else len(txt) + return txt[left + 1 : right + 1].strip() + + +def detect(expose: str) -> list[dict]: + """Retourne une mention par famille de formulation trouvée (dédupliquée par libellé). + + Pour chaque famille, on parcourt toutes les occurrences : une occurrence exclue + (acteur public, référent non-acteur) n'empêche pas une occurrence valide plus loin. + """ + mentions: dict[str, dict] = {} + for formulation, pattern, exclude_public, extra_exclude in PATTERNS: + for m in pattern.finditer(expose): + window = expose[m.end() : m.end() + _WINDOW] + if exclude_public and PUBLIC_ACTORS.search(window): + continue + if extra_exclude is not None and extra_exclude.match(window): + continue + mentions[formulation] = { + "citation": sentence_around(expose, m.start(), m.end()), + "formulation": formulation, + } + break + return list(mentions.values()) + + +def fetch_amendements(limit: int | None): + """Return (uid, exposeSommaire) for all eligible amendments.""" + query = ( + 'SELECT uid, "exposeSommaire" FROM amendements ' + 'WHERE "exposeSommaire" IS NOT NULL AND length("exposeSommaire") > 40 ' + 'ORDER BY "numeroOrdreDepot"' + ) + if limit: + query += " LIMIT :limit" + with get_engine().connect() as conn: + return conn.execute(text(query), {"limit": limit}).all() + + +def persist_mentions(session: Session, uid: str, mentions: list[dict]): + """Réécrit les lignes regex d'un amendement, sans toucher celles des autres modèles.""" + session.execute( + delete(AmendementMention).where( + AmendementMention.amendementUid == uid, + AmendementMention.modele == MODELE, + ) + ) + for m in mentions: + session.add( + AmendementMention( + amendementUid=uid, + citation=m["citation"], + formulation=m["formulation"], + modele=MODELE, + ) + ) + session.commit() + + +def run(limit: int | None = None, persist: bool = False): + load_dotenv() + rows = fetch_amendements(limit) + dest = "base + JSONL" if persist else "JSONL" + print(f"Analyse regex de {len(rows)} amendements (sortie: {dest})...") + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out_path = OUTPUT_DIR / "mentions_regex.jsonl" + + formulations: dict[str, int] = {} + nb_avec_mention = 0 + session = Session(get_engine()) if persist else None + + try: + with out_path.open("w", encoding="utf-8") as out: + for uid, expose in rows: + mentions = detect(expose) + out.write( + json.dumps({"uid": uid, "mentions": mentions}, ensure_ascii=False) + + "\n" + ) + if session is not None: + persist_mentions(session, uid, mentions) + if mentions: + nb_avec_mention += 1 + for m in mentions: + f = m["formulation"] + formulations[f] = formulations.get(f, 0) + 1 + finally: + if session is not None: + session.close() + + print(f"\n{nb_avec_mention}/{len(rows)} amendements avec au moins une mention.") + print("Formulations rencontrées (fréquence) :") + for formulation, count in sorted( + formulations.items(), key=lambda kv: kv[1], reverse=True + ): + print(f" {count:3d} {formulation}") + print(f"\nRésultats détaillés : {out_path}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Limiter le nombre d'amendements (défaut : tout le corpus)", + ) + parser.add_argument( + "--persist", + action="store_true", + help="Écrit aussi les mentions dans amendement_mentions (modele='regex:v1')", + ) + args = parser.parse_args() + run(args.limit, args.persist) + + +if __name__ == "__main__": + main() diff --git a/etl/database.py b/etl/database.py index aa7bc90..9ed5e95 100644 --- a/etl/database.py +++ b/etl/database.py @@ -5,6 +5,25 @@ import models # noqa: F401 # pyright: ignore[reportUnusedImport] # registers all ORM models with Base.metadata from models.base import Base +# Tables alimentées par l'ETL depuis les fichiers JSON de ./data. +# Seules ces tables sont détruites lors d'un rebuild et parcourues par l'ETL. +# Les tables d'analyse (ajoutées plus tard) en sont volontairement exclues afin +# que leurs résultats survivent à un rebuild et ne soient pas traitées comme des +# fichiers source à charger. +ETL_TABLES = { + "dossiers", + "amendements", + # Objets ajoutés pour les recoupements auteur / groupe / texte / vote agrégé. + "acteurs", + "organes", + "mandats", + "scrutins", + "groupesVotants", + "documents", + "auteursDocument", + "coSignatairesDocument", +} + def _get_db_url(): PG_USER = getenv("PG_USER") @@ -24,21 +43,33 @@ def _get_db_url(): def get_engine(): """Return a configured SQLAlchemy engine""" - PG_ECHO = getenv("PG_ECHO", False) + # getenv renvoie une chaîne : bool("False") vaudrait True, d'où la comparaison explicite. + pg_echo = getenv("PG_ECHO", "").strip().lower() == "true" pg_url = _get_db_url() - return create_engine(pg_url, poolclass=pool.NullPool, echo=bool(PG_ECHO)) + return create_engine(pg_url, poolclass=pool.NullPool, echo=pg_echo) + + +def _get_etl_tables(): + """Return the schema definitions of the ETL-managed tables only.""" + return [table for table in Base.metadata.sorted_tables if table.name in ETL_TABLES] def create_db(): - """Drop the current DB and recreate from the schema.""" + """Rebuild the ETL-managed tables from the schema. + + Only the tables listed in ETL_TABLES are dropped and recreated. Analysis + tables are left untouched so their results survive a rebuild; create_all is + idempotent and (re)creates any missing table without altering existing ones. + """ print("Creating DB") engine = get_engine() - print(Base.metadata.tables) - Base.metadata.drop_all(engine) + etl_tables = _get_etl_tables() + print(etl_tables) + Base.metadata.drop_all(engine, tables=etl_tables) Base.metadata.create_all(engine) print("Db was created") return Base.metadata.tables def get_tables_definition(): - return Base.metadata.sorted_tables + return _get_etl_tables() diff --git a/etl/download.py b/etl/download.py index 1dc0ad3..e27e75b 100644 --- a/etl/download.py +++ b/etl/download.py @@ -5,12 +5,35 @@ import httpx LEGISLATURE = 17 -# Liste des apis à télécharger -APIS = ["dossiers", "documents", "amendements"] -MAX_PAGE = 1000 +# APIs à télécharger, avec pour chacune : faut-il filtrer par législature ? +# True -> ajoute &legislature=17. Réservé aux endpoints qui exposent ce filtre +# dans l'API ET pour lesquels il est pertinent de se limiter à la L17. +# False -> pas de filtre. Nécessaire pour : +# - les référentiels trans-législature (`acteurs` renvoie même une 500 avec +# le filtre ; `organes` est partagé et on le veut complet pour éviter des +# références orphelines) ; +# - les endpoints qui n'exposent pas de paramètre `legislature` +# (`auteursDocument`, `coSignatairesDocument`, `groupesVotants`) : on les +# scope alors à la L17 par jointure (sur `documents` ou `scrutins`) au +# moment de l'analyse. +APIS = { + "dossiers": True, + "documents": True, + "amendements": True, + "acteurs": False, + "organes": False, + "mandats": True, + "scrutins": True, + "auteursDocument": False, + "coSignatairesDocument": False, + "groupesVotants": False, +} + BATCH_SIZE = 500 BASE_URL = "https://parlement.tricoteuses.fr/" +TIMEOUT = 90 +MAX_RETRIES = 3 def save(data, filename): @@ -19,40 +42,50 @@ def save(data, filename): json.dump(data, f) -def get(page, base_url): - params = f"?page={page}&perPage={BATCH_SIZE}&legislature={LEGISLATURE}" +def get(page, base_url, with_legislature): + """Récupère une page, avec quelques tentatives : l'API ferme parfois la + connexion en cours de route sur les gros volumes.""" + params = f"?page={page}&perPage={BATCH_SIZE}" + if with_legislature: + params += f"&legislature={LEGISLATURE}" url = base_url + params - try: - response = httpx.get(url, timeout=20) - response.raise_for_status() - return response - except Exception as e: - print(f"Error in Download: {e}") - return None + for attempt in range(1, MAX_RETRIES + 1): + try: + response = httpx.get(url, timeout=TIMEOUT) + response.raise_for_status() + return response + except Exception as e: + print(f"\tpage {page} tentative {attempt}/{MAX_RETRIES}: {e}") + sleep(3 * attempt) + return None -def get_api_data(api): +def get_api_data(api, with_legislature): base_url = BASE_URL + api + "/json" data = [] - for page in range(1, MAX_PAGE): - print("\tpage: ", page) - response = get(page, base_url) + page = 1 + while True: + response = get(page, base_url, with_legislature) if response is None: - break + # On abandonne l'endpoint plutôt que de sauvegarder un fichier tronqué. + raise RuntimeError( + f"Abandon de {api} à la page {page} après {MAX_RETRIES} tentatives" + ) - current_batch_data = response.json() - if len(current_batch_data["data"]) == 0: + current_batch_data = response.json()["data"] + if len(current_batch_data) == 0: break - data.extend(current_batch_data["data"]) - + data.extend(current_batch_data) + print(f"\t{api} page {page}: +{len(current_batch_data)} (total {len(data)})") + page += 1 sleep(0.3) return data def run_download(): - for api in APIS: + for api, with_legislature in APIS.items(): print("Fetching ", api) - data = get_api_data(api) + data = get_api_data(api, with_legislature) save(data, api) diff --git a/etl/loading.py b/etl/loading.py index 99b7500..32a7a4a 100644 --- a/etl/loading.py +++ b/etl/loading.py @@ -1,12 +1,65 @@ +from sqlalchemy import tuple_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session from etl.database import get_engine +# Nombre de lignes par INSERT. Le protocole Postgres plafonne à 65 535 paramètres +# par requête : avec ~40 colonnes, 1 000 lignes restent largement sous la limite. +# Indispensable pour les tables volumineuses (mandats, coSignatairesDocument…) qui +# dépasseraient sinon la limite en un seul INSERT. +BATCH_SIZE = 1000 + +# Colonnes ignorées pour décider si une ligne a changé. `dateMaj` est l'horodatage +# du lot d'export des tricoteuses, pas une date de modification de la ligne : sur +# 7 tables sur 9 il porte la même valeur pour toutes les lignes et change à chaque +# téléchargement. Le comparer reviendrait à réécrire l'intégralité de ces tables à +# chaque exécution. +IGNORED_FOR_COMPARISON = {"dateMaj"} + + +def _get_primary_key(table): + return [column.name for column in table.primary_key.columns] + + +def _deduplicate(table, data): + """Ne garder qu'une ligne par clé primaire. + + La pagination de l'API sert parfois deux fois la même entrée (~640 doublons + sur organes, ~77 sur scrutins). Postgres interdit qu'un ON CONFLICT DO UPDATE + touche deux fois la même ligne dans une seule commande : sans déduplication, + l'INSERT échoue. + """ + keys = _get_primary_key(table) + unique = {tuple(row[key] for key in keys): row for row in data} + return list(unique.values()) + + +def _upsert(table, batch): + """INSERT ... ON CONFLICT DO UPDATE n'écrivant que les lignes réellement modifiées. + + Le WHERE compare la ligne existante à celle proposée : sans lui, chaque + exécution réécrirait toutes les lignes. Or un UPDATE Postgres n'est jamais + fait sur place (nouvelle version du tuple, index et WAL mis à jour), ce qui + ferait gonfler la base pour rien. + """ + statement = insert(table).values(batch) + keys = _get_primary_key(table) + updatable = [column.name for column in table.columns if column.name not in keys] + compared = [name for name in updatable if name not in IGNORED_FOR_COMPARISON] + return statement.on_conflict_do_update( + index_elements=keys, + set_={name: statement.excluded[name] for name in updatable}, + where=tuple_(*(table.c[name] for name in compared)).is_distinct_from( + tuple_(*(statement.excluded[name] for name in compared)) + ), + ) + def load(table, data): - """Load into the database the data for the given_fields""" + """Load into the database the data for the given fields, in batches.""" + data = _deduplicate(table, data) with Session(get_engine()) as session: - insert_statement = insert(table).values(data).on_conflict_do_nothing() - session.execute(insert_statement) + for start in range(0, len(data), BATCH_SIZE): + session.execute(_upsert(table, data[start : start + BATCH_SIZE])) session.commit() diff --git a/justfile b/justfile index 2876808..fa15c64 100644 --- a/justfile +++ b/justfile @@ -21,3 +21,8 @@ all: # Run psql to explore the database psql: psql -h localhost -U postgres -d ipolitics + +# Detect external-collaboration mentions in amendments with regexes +# Extra flags pass through, e.g.: just detect-mentions-regex --persist --limit 100 +detect-mentions-regex *ARGS: + uv run python -m analysis.detect_mentions_regex {{ARGS}} diff --git a/models/__init__.py b/models/__init__.py index efb2b64..84e128a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1 +1,11 @@ +from models.acteur import Acteur # noqa: F401 +from models.amendement import Amendement # noqa: F401 +from models.amendement_mention import AmendementMention # noqa: F401 +from models.auteur_document import AuteurDocument # noqa: F401 +from models.cosignataire_document import CoSignataireDocument # noqa: F401 +from models.document import Document # noqa: F401 from models.dossier import Dossier # noqa: F401 +from models.groupe_votant import GroupeVotant # noqa: F401 +from models.mandat import Mandat # noqa: F401 +from models.organe import Organe # noqa: F401 +from models.scrutin import Scrutin # noqa: F401 diff --git a/models/acteur.py b/models/acteur.py new file mode 100644 index 0000000..c7a3a30 --- /dev/null +++ b/models/acteur.py @@ -0,0 +1,64 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Acteur(Base): + """Acteur parlementaire (député / sénateur) tel que renvoyé par l'API des + tricoteuses (endpoint /acteurs). + + Référentiel trans-législature : `uid` (PA…) est stable d'une législature à + l'autre, le rattachement à un groupe/commission passe par la table `mandats`. + Noms de colonnes en camelCase = clés JSON (voir Amendement pour la convention). + """ + + __tablename__ = "acteurs" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + prenom: Mapped[str | None] + nom: Mapped[str | None] + civ: Mapped[str | None] + slug: Mapped[str | None] + chambre: Mapped[str | None] + actif: Mapped[bool | None] + + # --- État civil --- + dateNais: Mapped[str | None] + dateDeces: Mapped[str | None] + villeNais: Mapped[str | None] + depNais: Mapped[str | None] + paysNais: Mapped[str | None] + + # --- Profession --- + profession: Mapped[str | None] + catSocPro: Mapped[str | None] + famSocPro: Mapped[str | None] + + # --- Rattachements (références molles indexées) --- + groupeParlementaireUid: Mapped[str | None] = mapped_column(index=True) + mandatPrincipalUid: Mapped[str | None] = mapped_column(index=True) + circonscriptionUid: Mapped[str | None] = mapped_column(index=True) + commissionPermanenteRefUid: Mapped[str | None] = mapped_column(index=True) + fonctionCommissionPermanente: Mapped[str | None] + placeHemicycle: Mapped[str | None] + + # --- Divers --- + uriHatvp: Mapped[str | None] + urlImage: Mapped[str | None] + compteTwitter: Mapped[str | None] + senatMatricule: Mapped[str | None] + + # --- Compteurs d'activité --- + nombreAmendements: Mapped[int | None] + nombreAmendementsAdoptes: Mapped[int | None] + nombreInterventions: Mapped[int | None] + nombreQuestions: Mapped[int | None] + nombreQuestionsRepondues: Mapped[int | None] + nombreDocumentsPublies: Mapped[int | None] + nombreMandats: Mapped[int | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/amendement.py b/models/amendement.py new file mode 100644 index 0000000..86f91cf --- /dev/null +++ b/models/amendement.py @@ -0,0 +1,70 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Amendement(Base): + """Amendement brut tel que renvoyé par l'API des tricoteuses (endpoint /amendements). + + Les noms d'attributs sont en camelCase afin de correspondre exactement aux clés + du JSON source : l'ETL (etl/extraction.py) s'appuie sur cette correspondance + 1:1 entre nom de colonne et nom de champ JSON. + + Seul `uid` est non nullable (clé primaire) ; les autres champs restent nullable + car les amendements sont hétérogènes (budgétaires, sous-amendements...) et + n'exposent pas toujours l'ensemble des champs. + """ + + __tablename__ = "amendements" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + numeroLong: Mapped[str | None] + numeroOrdreDepot: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + dataset: Mapped[int | None] + + # --- Texte (contenu analysé) --- + exposeSommaire: Mapped[str | None] = mapped_column(Text) + dispositif: Mapped[str | None] = mapped_column(Text) + + # --- Auteur / signataires (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + groupePolitiqueRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + typeAuteur: Mapped[str | None] + nomRepresentation: Mapped[str | None] + signatairesLibelle: Mapped[str | None] + nombreCoSignataires: Mapped[int | None] + + # --- Objet visé dans le texte --- + divisionArticleDesignation: Mapped[str | None] + alineaDesignation: Mapped[str | None] + + # --- Rattachement (références molles indexées vers d'autres entités de l'API) --- + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + etapeLegislativeRefUid: Mapped[str | None] + codeEtape: Mapped[str | None] + seanceRefUid: Mapped[str | None] + # Renseigné quand l'amendement a été tranché par un scrutin public. Couvre deux + # fois plus d'amendements que le lien inverse `scrutins.amendementRefUid`, un même + # scrutin pouvant trancher plusieurs amendements identiques. + scrutinRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Statut / sort --- + sortAmendement: Mapped[str | None] + etatCode: Mapped[str | None] + etatLibelle: Mapped[str | None] + triAmendement: Mapped[str | None] + # L'API renvoie ici les chaînes "true"/"false" et non un booléen JSON. + soumisArticle40: Mapped[str | None] + + # --- Dates (conservées en texte : l'ETL insère les valeurs JSON brutes) --- + dateDepot: Mapped[str | None] + dateSort: Mapped[str | None] + dateMaj: Mapped[str | None] + datePublication: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/amendement_mention.py b/models/amendement_mention.py new file mode 100644 index 0000000..27024a1 --- /dev/null +++ b/models/amendement_mention.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class AmendementMention(Base): + """Mention de collaboration externe détectée dans l'exposé sommaire d'un amendement. + + Table d'ANALYSE (pas alimentée par l'ETL) : elle n'est donc pas listée dans + ETL_TABLES et survit aux rebuilds. Un amendement peut porter plusieurs mentions, + d'où une clé primaire de substitution et une ligne par mention. + + `amendementUid` est une référence molle vers `amendements.uid` (pas de ForeignKey) : + la table `amendements` étant recréée à chaque rebuild, une contrainte référentielle + bloquerait son drop. On suit ici la même logique que les RefUid du modèle Amendement. + + Les champs métier reprennent le schéma produit par le détecteur + (analysis/detect_mentions_regex.py). + """ + + __tablename__ = "amendement_mentions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # Référence molle vers amendements.uid (indexée pour les jointures applicatives). + amendementUid: Mapped[str] = mapped_column(index=True) + + # Passage exact recopié depuis l'exposé sommaire. + citation: Mapped[str] = mapped_column(Text) + # Expression déclencheuse, ex. « travaillé avec », « en lien avec ». + formulation: Mapped[str | None] + # Nom de l'entité citée, ou NULL si non nommée. + entite: Mapped[str | None] + # lobby|association|syndicat|entreprise|federation_professionnelle|ong| + # think_tank|collectif_citoyen|organe_public|autre|inconnu + typeEntite: Mapped[str | None] + # True si acteur d'intérêt privé/externe, False si institution publique. + externe: Mapped[bool | None] = mapped_column(Boolean) + + # Provenance : le modèle varie pendant le POC, on trace ce qui a produit la ligne. + modele: Mapped[str | None] + createdAt: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/models/auteur_document.py b/models/auteur_document.py new file mode 100644 index 0000000..e9e1c8d --- /dev/null +++ b/models/auteur_document.py @@ -0,0 +1,28 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class AuteurDocument(Base): + """Auteur d'un document parlementaire (endpoint /auteursDocument). + + Relie un acteur (ou un organe) à un document (texte de loi, rapport…) avec sa + `qualite` (auteur, rapporteur…). Sert à savoir qui dépose / porte un texte + (`acteurRefUid` → acteurs, `documentRefUid` → documents). + """ + + __tablename__ = "auteursDocument" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + qualite: Mapped[str | None] + + # --- Rattachement (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/cosignataire_document.py b/models/cosignataire_document.py new file mode 100644 index 0000000..f47fcae --- /dev/null +++ b/models/cosignataire_document.py @@ -0,0 +1,31 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class CoSignataireDocument(Base): + """Co-signataire d'un document parlementaire (endpoint /coSignatairesDocument). + + Relie un acteur à un document qu'il co-signe, avec les dates de (co)signature et + d'éventuel retrait. Complète `auteursDocument` pour reconstituer l'ensemble des + soutiens d'un texte. + """ + + __tablename__ = "coSignatairesDocument" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + edite: Mapped[bool | None] + etApparentes: Mapped[bool | None] + + # --- Rattachement (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Dates --- + dateCosignature: Mapped[str | None] + dateRetraitCosignature: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/document.py b/models/document.py new file mode 100644 index 0000000..eb51b6e --- /dev/null +++ b/models/document.py @@ -0,0 +1,74 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Document(Base): + """Document parlementaire (endpoint /documents) : les textes eux-mêmes — projets + et propositions de loi, rapports, accords internationaux… + + Se rattache au dossier législatif par `dossierRefUid` et porte l'auteur principal + (`auteurPrincipalUid`). L'exposé des motifs (`exposeMotifsTexte`) est l'équivalent, + côté texte, de l'exposé sommaire d'un amendement. + """ + + __tablename__ = "documents" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + numNotice: Mapped[str | None] + texteLoi: Mapped[bool | None] + amendable: Mapped[bool | None] + estDisponible: Mapped[bool | None] + adoptionConforme: Mapped[bool | None] + + # --- Titres / contenu --- + titrePrincipal: Mapped[str | None] = mapped_column(Text) + titrePrincipalCourt: Mapped[str | None] = mapped_column(Text) + formule: Mapped[str | None] = mapped_column(Text) + exposeMotifsTexte: Mapped[str | None] = mapped_column(Text) + exposeMotifsHtml: Mapped[str | None] = mapped_column(Text) + denominationStructurelle: Mapped[str | None] + + # --- Classification --- + classeCode: Mapped[str | None] + classeLibelle: Mapped[str | None] + typeCode: Mapped[str | None] + typeLibelle: Mapped[str | None] + sousTypeCode: Mapped[str | None] + sousTypeLibelle: Mapped[str | None] + sousTypeLibelleEdition: Mapped[str | None] + especeCode: Mapped[str | None] + especeLibelle: Mapped[str | None] + depotCode: Mapped[str | None] + depotLibelle: Mapped[str | None] + provenance: Mapped[str | None] + statutAdoption: Mapped[str | None] + niveauCorrection: Mapped[str | None] + typeCorrection: Mapped[str | None] + xsiType: Mapped[str | None] + + # --- Rattachement (références molles indexées) --- + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentParentRefUid: Mapped[str | None] = mapped_column(index=True) + auteurPrincipalUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + etapeLegislativePrincipaleRefUid: Mapped[str | None] + + # --- Divers --- + nbPage: Mapped[str | None] + prix: Mapped[str | None] + isbn: Mapped[str | None] + pdfUrl: Mapped[str | None] + + # --- Dates --- + dateCreation: Mapped[str | None] + dateDepot: Mapped[str | None] + datePublication: Mapped[str | None] + datePublicationWeb: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/dossier.py b/models/dossier.py index f46927a..6ae850d 100644 --- a/models/dossier.py +++ b/models/dossier.py @@ -5,8 +5,25 @@ class Dossier(Base): + """Dossier législatif (endpoint /dossiers) : le fil qui suit un texte tout au long + de la procédure (commission, séance, navette avec le Sénat, amendements...). + + L'endpoint expose bien plus de champs que ceux repris ici ; on ne charge que ceux + utiles aux recoupements. + """ + __tablename__ = "dossiers" + # --- Identité --- uid: Mapped[str] = mapped_column(primary_key=True) titre: Mapped[str] = mapped_column(String(1000)) dataset: Mapped[int] + legislature: Mapped[int | None] + + # --- Procédure --- + codeProcedure: Mapped[str | None] + libelleProcedure: Mapped[str | None] + + # --- Avancement --- + statut: Mapped[str | None] + dateDernierActe: Mapped[str | None] diff --git a/models/groupe_votant.py b/models/groupe_votant.py new file mode 100644 index 0000000..b0b5479 --- /dev/null +++ b/models/groupe_votant.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class GroupeVotant(Base): + """Résultat d'un scrutin ventilé par groupe politique (endpoint /groupesVotants). + + Une ligne par couple (scrutin, groupe) : le décompte du groupe et sa position + majoritaire. Le détail nominatif (endpoint /votes) n'est pas chargé. + + L'endpoint n'expose pas de filtre `legislature` : la table couvre toutes les + législatures et se scope à la 17ᵉ par jointure sur `scrutins`. + """ + + __tablename__ = "groupesVotants" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + + # --- Rattachement (références molles indexées) --- + scrutinRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Décompte du groupe --- + positionMajoritaire: Mapped[str | None] + nombreMembresGroupe: Mapped[int | None] + pour: Mapped[int | None] + contre: Mapped[int | None] + abstentions: Mapped[int | None] + nonVotants: Mapped[int | None] + nonVotantsVolontaires: Mapped[int | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/mandat.py b/models/mandat.py new file mode 100644 index 0000000..80308dd --- /dev/null +++ b/models/mandat.py @@ -0,0 +1,58 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Mandat(Base): + """Mandat parlementaire (endpoint /mandats) : table de jointure entre un acteur + et un organe (appartenance à un groupe, une commission, une délégation…), avec + la qualité occupée et les dates de début/fin. + + C'est le pivot pour rattacher un acteur à son groupe politique à une date donnée + (`acteurRefUid` → acteurs, `organeRefUid` → organes). + """ + + __tablename__ = "mandats" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + typeOrgane: Mapped[str | None] + xsiType: Mapped[str | None] + actif: Mapped[bool | None] + nominPrincipale: Mapped[bool | None] + + # --- Rattachement (références molles indexées pour les jointures) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + mandatRemplaceRefUid: Mapped[str | None] + missionPrecedenteRefUid: Mapped[str | None] + + # --- Qualité --- + codeQualite: Mapped[str | None] + libQualite: Mapped[str | None] + libQualiteSex: Mapped[str | None] + libelle: Mapped[str | None] + causeMandat: Mapped[str | None] + causeFin: Mapped[str | None] + + # --- Circonscription / territoire --- + refCirconscription: Mapped[str | None] + region: Mapped[str | None] + regionType: Mapped[str | None] + departement: Mapped[str | None] + numDepartement: Mapped[int | None] + numCirco: Mapped[int | None] + placeHemicycle: Mapped[str | None] + preseance: Mapped[int | None] + premiereElection: Mapped[str | None] + + # --- Dates --- + dateDebut: Mapped[str | None] + dateFin: Mapped[str | None] + datePriseFonction: Mapped[str | None] + datePublication: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/organe.py b/models/organe.py new file mode 100644 index 0000000..f0cf015 --- /dev/null +++ b/models/organe.py @@ -0,0 +1,71 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Organe(Base): + """Organe parlementaire (endpoint /organes) : groupes politiques, commissions, + assemblées, missions, délégations… + + Le `codeType` distingue la nature de l'organe (ex. GP = groupe politique, + COMPER = commission permanente). Les groupes votants et les mandats pointent + vers cet identifiant `uid` (PO…) via leur `organeRefUid`. + """ + + __tablename__ = "organes" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + codeType: Mapped[str | None] + type: Mapped[str | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + actif: Mapped[bool | None] + regime: Mapped[str | None] + regimeJuridique: Mapped[str | None] + xsiType: Mapped[str | None] + + # --- Libellés --- + libelle: Mapped[str | None] = mapped_column(Text) + libelleEdition: Mapped[str | None] = mapped_column(Text) + libelleAbrege: Mapped[str | None] + libelleAbrev: Mapped[str | None] + libelleTronque: Mapped[str | None] + + # --- Caractérisation politique (groupes) --- + positionPolitique: Mapped[str | None] + couleurAssociee: Mapped[str | None] + poids: Mapped[int | None] + preseance: Mapped[int | None] + cohesion: Mapped[int | None] + + # --- Rattachement / contacts --- + organeParentRefUid: Mapped[str | None] = mapped_column(index=True) + secretaire01: Mapped[str | None] + secretaire02: Mapped[str | None] + siteInternet: Mapped[str | None] = mapped_column(Text) + urlImage: Mapped[str | None] + senatCode: Mapped[str | None] + numCirco: Mapped[str | None] + numDepartement: Mapped[str | None] + + # --- Compteurs d'activité --- + nombreMembres: Mapped[int | None] + nombreReunionsAnnuelles: Mapped[int | None] + auditionsRealisees: Mapped[int | None] + dossiersLoiTraites: Mapped[int | None] + missionsDemarrees: Mapped[int | None] + nombreAmendementsProposes: Mapped[int | None] + nombreInterventions: Mapped[int | None] + nombreQuestions: Mapped[int | None] + nombreTextesLoisDeposes: Mapped[int | None] + rapportsPublies: Mapped[int | None] + + # --- Dates --- + dateDebut: Mapped[str | None] + dateFin: Mapped[str | None] + dateAgrement: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/scrutin.py b/models/scrutin.py new file mode 100644 index 0000000..096f933 --- /dev/null +++ b/models/scrutin.py @@ -0,0 +1,68 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Scrutin(Base): + """Scrutin public (endpoint /scrutins) : un vote solennel de l'hémicycle sur un + objet donné (amendement, article, ensemble d'un texte, motion…), avec le résultat + agrégé (pour / contre / abstentions). + + Les références molles permettent de relier le scrutin à ce qui était voté : + `dossierRefUid`, `documentRefUid`, `amendementRefUid`. Le détail par groupe est + dans `groupesVotants`, le détail nominatif dans `votes`. + """ + + __tablename__ = "scrutins" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + numero: Mapped[str | None] + dateScrutin: Mapped[str | None] + demandeur: Mapped[str | None] + + # --- Objet voté --- + objet: Mapped[str | None] = mapped_column(Text) + titre: Mapped[str | None] = mapped_column(Text) + typeObjet: Mapped[str | None] + numeroTypeObjet: Mapped[str | None] + + # --- Type de scrutin / résultat --- + codeTypeVote: Mapped[str | None] + libelleTypeVote: Mapped[str | None] + modePublicationDesVotes: Mapped[str | None] + typeMajorite: Mapped[str | None] + code: Mapped[str | None] + libelle: Mapped[str | None] + annonce: Mapped[str | None] + + # --- Décompte --- + pour: Mapped[int | None] + contre: Mapped[int | None] + abstentions: Mapped[int | None] + nonVotants: Mapped[int | None] + nonVotantsVolontaires: Mapped[int | None] + nombreVotants: Mapped[int | None] + suffragesExprimes: Mapped[int | None] + nbrSuffragesRequis: Mapped[int | None] + + # --- Rattachement (références molles indexées) --- + organeRefUid: Mapped[str | None] = mapped_column(index=True) + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + amendementRefUid: Mapped[str | None] = mapped_column(index=True) + articleRefUid: Mapped[str | None] + seanceRefUid: Mapped[str | None] + pointOdjRefUid: Mapped[str | None] + acteLegislatifRefUid: Mapped[str | None] + etapeLegislativeRefUid: Mapped[str | None] + codeEtape: Mapped[str | None] + quantiemeJourSeance: Mapped[str | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None]