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}} 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() + )