Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ PG_PORT=5432

# Mettre à True pour logguer toutes les requêtes SQL émises par SQLAlchemy.
PG_ECHO=False

# Analyse des amendements via une API OpenAI-compatible (Scaleway, OpenRouter...).
# LLM_API_KEY : clé secrète du provider.
# LLM_BASE_URL : endpoint OpenAI-compatible du provider.
# LLM_MODEL : identifiant exact du modèle (confirmer via la liste des modèles).
#
# OpenRouter : LLM_BASE_URL=https://openrouter.ai/api/v1 LLM_MODEL=qwen/qwen3-30b-a3b
# Scaleway : LLM_BASE_URL=https://api.scaleway.ai/v1 LLM_MODEL=qwen3-30b-a3b
LLM_API_KEY=
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_MODEL=qwen/qwen3-30b-a3b
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
88 changes: 85 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,25 @@ 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
```
Il contient la connexion PostgreSQL (`PG_*`) et la configuration du modèle de langage
utilisé par l'analyse des amendements (`LLM_*`, voir plus bas).

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
Expand Down Expand Up @@ -112,16 +127,83 @@ 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
```

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… ».

Version **v1 exploratoire** : chaque amendement est soumis à un modèle de langage
(`analysis/detect_mentions.py`).

## Prérequis

1. La base doit être alimentée (table `amendements` peuplée) — voir les sections ETL ci-dessus.
2. Configurer l'accès au modèle de langage dans `.env`. Le script utilise le SDK OpenAI contre
**n'importe quelle API compatible OpenAI** (Scaleway, OpenRouter…) via trois variables :

```dotenv
LLM_API_KEY=... # clé du provider
LLM_BASE_URL=... # endpoint OpenAI-compatible
LLM_MODEL=... # identifiant exact du modèle
```

Changer de provider = changer ces trois lignes.

## Lancer une analyse

```bash
# Sur les 50 premiers amendements (par ordre de dépôt)
just detect-mentions

# Sur 100 amendements tirés aléatoirement dans tout le corpus
just detect-mentions 100 --random

# Idem, en persistant les résultats dans la table amendement_mentions
just detect-mentions 100 --random --persist

# Équivalent sans just :
uv run python -m analysis.detect_mentions --limit 100 --random --persist
```

Options (`uv run python -m analysis.detect_mentions --help`) :

| Option | Effet |
| ----------- | --------------------------------------------------------------------------- |
| `--limit N` | Nombre d'amendements à analyser (défaut : 50). |
| `--offset N`| Décalage dans l'échantillon déterministe (ignoré avec `--random`). |
| `--random` | Tirage aléatoire sur tout le corpus (utile car les mentions sont rares). |
| `--delay S` | Pause en secondes entre deux appels (throttle anti rate-limit, défaut : 4). |
| `--persist` | Écrit aussi les mentions dans la table `amendement_mentions`. |

## Sorties

- **JSONL brut** : `analysis/output/mentions_sample.jsonl` (une ligne par amendement, dossier
gitignoré). Réécrit à chaque exécution.
- **Récap console** : nombre d'amendements avec mention et fréquence des formulations rencontrées.
- **Base** (avec `--persist`) : table `amendement_mentions`, une ligne par mention détectée
(`amendementUid`, `citation`, `formulation`, `entite`, `typeEntite`, `externe`, `modele`,
`createdAt`). L'écriture est idempotente par amendement (une nouvelle passe remplace ses lignes).

## 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.
259 changes: 259 additions & 0 deletions analysis/detect_mentions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
"""Détection exploratoire (v1, utilsant uniquement un llm) des mentions de collaboration externe dans les amendements.

On envoie l'exposé sommaire de chaque amendement à un modèle (API OpenAI-compatible) et on lui demande de repérer les passages où l'auteur déclare
avoir « travaillé avec », « été inspiré par », etc. une entité externe (lobby,
association, syndicat, entreprise, fédération professionnelle, ONG...).

Usage:
uv run python -m analysis.detect_mentions --limit 50
uv run python -m analysis.detect_mentions --limit 100 --offset 100
"""

import argparse
import json
import os
import time
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI
from openai import RateLimitError
from sqlalchemy import delete, text
from sqlalchemy.orm import Session

from etl.database import get_engine
from models.amendement_mention import AmendementMention

OUTPUT_DIR = Path("analysis/output")

SYSTEM_PROMPT = """\
Tu analyses des amendements de l'Assemblée nationale française. On te donne l'exposé
sommaire d'un amendement (le texte par lequel l'auteur justifie sa proposition).

Ta tâche : repérer chaque passage où l'auteur déclare que l'amendement a été élaboré,
travaillé, inspiré, proposé ou suggéré EN LIEN AVEC UN ACTEUR EXTERNE — par exemple un
lobby, une association, un syndicat, une entreprise, une fédération professionnelle, une
ONG, un think tank, un collectif citoyen. Signale aussi les cas ambigus.

Ne compte PAS comme mention le simple fait de citer un acteur (« comme le rappelle
l'INSEE »). Cherche une déclaration de COLLABORATION ou d'INSPIRATION revendiquée par
l'auteur de l'amendement.

Réponds UNIQUEMENT avec un objet JSON valide de la forme :
{
"mentions": [
{
"citation": "<la phrase exacte tirée du texte, recopiée sans reformulation>",
"formulation": "<l'expression déclencheuse, ex: 'travaillé avec', 'à l'initiative de'>",
"entite": "<le nom de l'entité citée, ou null si non nommée>",
"type_entite": "<lobby|association|syndicat|entreprise|federation_professionnelle|ong|think_tank|collectif_citoyen|organe_public|autre|inconnu>",
"externe": <true si acteur d'intérêt privé/externe, false si institution publique>
}
]
}
Si aucune mention, renvoie {"mentions": []}.
"""

USER_TEMPLATE = 'Exposé sommaire de l\'amendement :\n\n"""\n{expose}\n"""'


def get_config():
api_key = os.getenv("LLM_API_KEY")
base_url = os.getenv("LLM_BASE_URL")
model = os.getenv("LLM_MODEL")
if not api_key:
raise SystemExit(
"LLM_API_KEY manquante. Renseigne la clé du provider dans le fichier .env."
)
return api_key, base_url, model


def fetch_amendements(limit: int, offset: int, random_sample: bool = False):
"""Return a sample of (uid, numero, expose) with a non-trivial exposé sommaire.

Par défaut, échantillon ordonné par `numeroOrdreDepot` (déterministe, paginable
via offset). Avec random_sample=True, tirage aléatoire sur tout le corpus — utile
en phase d'observation car les mentions sont rares et concentrées nulle part.
"""
order_by = "random()" if random_sample else '"numeroOrdreDepot"'
query = text(
'SELECT uid, "numeroOrdreDepot", "exposeSommaire" '
"FROM amendements "
'WHERE "exposeSommaire" IS NOT NULL AND length("exposeSommaire") > 40 '
f"ORDER BY {order_by} "
"LIMIT :limit OFFSET :offset"
)
with get_engine().connect() as conn:
rows = conn.execute(query, {"limit": limit, "offset": offset}).all()
return rows


def parse_json(content: str) -> dict:
"""Extract a JSON object from a model reply, tolerating fences and prose.

Les modèles OpenRouter ne supportent pas tous le mode `json_object` ; on
n'impose donc aucun `response_format` et on récupère l'objet JSON à la main.
"""
if not content or not content.strip():
# Réponse vide du modèle : on considère qu'il n'y a pas de mention.
return {"mentions": []}
text = content.strip()
if text.startswith("```"):
# Retire un éventuel bloc ```json ... ```
text = text.split("```", 2)[1] if text.count("```") >= 2 else text
text = text.removeprefix("json").strip()
start, end = text.find("{"), text.rfind("}")
if start != -1 and end != -1 and end > start:
text = text[start : end + 1]
return json.loads(text)


def detect(client: OpenAI, model: str, expose: str, max_retries: int = 4) -> dict:
"""Ask the model to extract collaboration mentions from one exposé sommaire.

Réessaie sur 429 (rate limit) avec un backoff exponentiel plafonné : utile sur
les modèles `:free` d'OpenRouter, très limités en requêtes/minute.
"""
for attempt in range(max_retries + 1):
try:
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_TEMPLATE.format(expose=expose)},
],
)
break
except RateLimitError:
if attempt == max_retries:
raise
wait = min(2**attempt, 30)
print(f" 429 rate-limited, nouvelle tentative dans {wait}s...")
time.sleep(wait)
content = response.choices[0].message.content
return parse_json(content)


def persist_mentions(session: Session, uid: str, mentions: list[dict], model: str):
"""Ecrit ou réécrit les mentions d'un amendement en base (delete puis insert).

Idempotent : un re-run sur le même amendement remplace ses lignes existantes.
"""
session.execute(
delete(AmendementMention).where(AmendementMention.amendementUid == uid)
)
for m in mentions:
externe = m.get("externe")
session.add(
AmendementMention(
amendementUid=uid,
citation=str(m.get("citation") or ""),
formulation=m.get("formulation"),
entite=m.get("entite"),
typeEntite=m.get("type_entite"),
externe=externe if isinstance(externe, bool) else None,
modele=model,
)
)
session.commit()


def run(
limit: int,
offset: int,
random_sample: bool = False,
delay: float = 4.0,
persist: bool = False,
):
load_dotenv()
api_key, base_url, model = get_config()
client = OpenAI(api_key=api_key, base_url=base_url)

rows = fetch_amendements(limit, offset, random_sample)
dest = "base + JSONL" if persist else "JSONL"
print(f"Analyse de {len(rows)} amendements (modèle: {model}, sortie: {dest})...")

OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
out_path = OUTPUT_DIR / "mentions_sample.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 i, (uid, numero, expose) in enumerate(rows, start=1):
if i > 1:
time.sleep(delay)
try:
result = detect(client, model, expose)
mentions = result.get("mentions", [])
except Exception as e: # noqa: BLE001 - on veut continuer le run
print(f" [{i}/{len(rows)}] {uid}: erreur -> {e}")
out.write(
json.dumps({"uid": uid, "error": str(e)}, ensure_ascii=False)
+ "\n"
)
continue

out.write(
json.dumps(
{"uid": uid, "numero": numero, "mentions": mentions},
ensure_ascii=False,
)
+ "\n"
)

if session is not None:
persist_mentions(session, uid, mentions, model)

if mentions:
nb_avec_mention += 1
for m in mentions:
formulation = (m.get("formulation") or "?").strip().lower()
formulations[formulation] = formulations.get(formulation, 0) + 1
print(f" [{i}/{len(rows)}] {uid}: {len(mentions)} mention(s)")
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=50, help="Nombre d'amendements à analyser"
)
parser.add_argument(
"--offset", type=int, default=0, help="Décalage dans le jeu de données"
)
parser.add_argument(
"--random",
action="store_true",
help="Tirage aléatoire sur tout le corpus (ignore l'offset)",
)
parser.add_argument(
"--delay",
type=float,
default=4.0,
help="Pause en secondes entre deux appels (throttle anti rate-limit)",
)
parser.add_argument(
"--persist",
action="store_true",
help="Écrit aussi les mentions dans la table amendement_mentions",
)
args = parser.parse_args()
run(args.limit, args.offset, args.random, args.delay, args.persist)


if __name__ == "__main__":
main()
Loading
Loading