From ace4954d73782a331c0326ea69f6b4cf51e062eb Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sun, 19 Jul 2026 11:09:27 +0200 Subject: [PATCH 1/2] Ajoute la table d'analyse amendement_mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stocke les mentions de collaboration externe détectées dans l'exposé sommaire des amendements. Table d'analyse (hors ETL_TABLES) : elle survit aux rebuilds via create_all, pendant que amendements/dossiers sont détruites puis rechargées. amendementUid est une référence molle vers amendements.uid (sans ForeignKey), sur le modèle des RefUid du modèle Amendement, afin qu'aucune contrainte référentielle ne bloque le drop de la table amendements gérée par l'ETL. Une ligne par mention ; provenance conservée via modele/createdAt. --- 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 f9e78d1..84e128a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,5 +1,6 @@ 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 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() + ) From ddb5b3c38cfcd24b0d3a9fe6d75cba72c979e205 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sun, 19 Jul 2026 11:09:28 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Ajoute=20le=20d=C3=A9tecteur=20regex=20des?= =?UTF-8?q?=20mentions=20de=20collaboration=20externe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repère les amendements dont l'exposé sommaire déclare une collaboration ou une inspiration avec une entité externe (lobby, syndicat, association, entreprise, ONG...). Les familles de motifs sont calibrées sur les formulations observées sur le corpus réel : travaillé avec, en concertation avec, inspiré de, issu d'une proposition de, proposé par, à la demande de... Des exclusions contextuelles maintiennent la précision : acteurs publics ou parlementaires juste après la tournure (Gouvernement, commission, rapporteur, Conseil d'État...), référents juridiques après « inspiré de » (loi, article, procédure...), référents textuels ou de rôle après les familles de type citation (le texte, le rapport, le groupe X, l'autorité...). Repérage seul : remplit citation + formulation, laisse entite/typeEntite/ externe à NULL (qualifier l'entité demande une analyse sémantique). Avec --persist, les lignes sont écrites dans amendement_mentions taguées modele='regex:v1' ; les ré-exécutions sont idempotentes par amendement et scopées à ce tag. Documente aussi l'usage du détecteur dans le README. --- .gitignore | 1 + README.md | 49 +++++ analysis/__init__.py | 0 analysis/detect_mentions_regex.py | 308 ++++++++++++++++++++++++++++++ justfile | 5 + 5 files changed, 363 insertions(+) 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 f66408a..bcbb846 100644 --- a/README.md +++ b/README.md @@ -273,3 +273,52 @@ pointe vers l'amendement tranché par le scrutin, et `amendements.scrutinRefUid` 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/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}}