From 8652bafe7be32acbe1b2c250f4c2afcc98bd7a53 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sun, 12 Jul 2026 10:53:26 +0200 Subject: [PATCH 1/3] Add amendement_mentions analysis table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores external-collaboration mentions detected in amendments' exposé sommaire. Analysis table (not in ETL_TABLES): survives rebuilds via create_all while amendements/dossiers are dropped and reloaded. amendementUid is a soft reference to amendements.uid (no ForeignKey), matching the RefUid pattern in the Amendement model, so the referential constraint never blocks the drop of the ETL-managed amendements table. One row per mention; provenance kept via modele/createdAt. (cherry picked from commit 726994fd1f9191aa23cd050d55b86f71f874de14) --- models/__init__.py | 1 + models/amendement_mention.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 models/amendement_mention.py diff --git a/models/__init__.py b/models/__init__.py index 187aec0..dd7df44 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,2 +1,3 @@ from models.amendement import Amendement # noqa: F401 +from models.amendement_mention import AmendementMention # noqa: F401 from models.dossier import Dossier # noqa: F401 diff --git a/models/amendement_mention.py b/models/amendement_mention.py new file mode 100644 index 0000000..d11a10f --- /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.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() + ) From c6bcdbcf747b090dd9ab118730fc0402e5111ec6 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Tue, 14 Jul 2026 13:08:19 +0200 Subject: [PATCH 2/3] Add regex-based external-collaboration mention detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects amendments whose exposé sommaire declares a collaboration or inspiration with an external entity (lobby, union, association, company, NGO...). Pattern families are calibrated on formulations observed on the real corpus: travaillé avec, en concertation avec, inspiré de, issu d'une proposition de, proposé par, à la demande de... Contextual exclusions keep precision up: public or parliamentary actors right after the wording (Gouvernement, commission, rapporteur, Conseil d'État...), legal-object referents after 'inspiré de' (loi, article, procédure...), and text/role referents after citation-like families (le texte, le rapport, le groupe X, l'autorité...). Detection only: fills citation + formulation, leaves entite/typeEntite/ externe NULL (qualifying the entity needs semantic analysis). With --persist, rows land in amendement_mentions tagged modele='regex:v1'; re-runs are idempotent per amendment and scoped to that tag. Also documents DB setup and detector usage in the README, and fixes the stale Dossier model example. --- .gitignore | 1 + README.md | 68 ++++++- analysis/__init__.py | 0 analysis/detect_mentions_regex.py | 308 ++++++++++++++++++++++++++++++ justfile | 5 + 5 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 analysis/__init__.py create mode 100644 analysis/detect_mentions_regex.py 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..528c021 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,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 +125,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 +138,52 @@ 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` + +# 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/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}} From 502f82a49848046dd3c711e4f02ef255befc08b6 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Tue, 14 Jul 2026 15:13:51 +0200 Subject: [PATCH 3/3] Batch ETL inserts to stay under Postgres parameter limit load() sent the whole dataset as a single INSERT: with the full corpus (123k amendments x ~30 columns) that exceeds the 65,535-parameter limit of the Postgres protocol and the load fails. Insert in batches of 1,000 rows instead (single transaction, still idempotent via on_conflict_do_nothing). --- etl/loading.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/etl/loading.py b/etl/loading.py index 99b7500..439ba8e 100644 --- a/etl/loading.py +++ b/etl/loading.py @@ -3,10 +3,15 @@ from etl.database import get_engine +# Nombre de lignes par INSERT. Le protocole Postgres plafonne à 65 535 paramètres +# par requête : avec ~30 colonnes, 1 000 lignes restent largement sous la limite. +BATCH_SIZE = 1000 + 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.""" 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): + batch = data[start : start + BATCH_SIZE] + session.execute(insert(table).values(batch).on_conflict_do_nothing()) session.commit()