Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Project ignore
data/*
analysis/output/


# From https://github.com/github/gitignore/blob/main/Python.gitignore
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Empty file added analysis/__init__.py
Empty file.
308 changes: 308 additions & 0 deletions analysis/detect_mentions_regex.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
1 change: 1 addition & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading