Add Légifrance API integration for live legal text injection

Implements a two-layer approach to ensure legal texts are accurate and current:

Option A — runtime injection (legifrance_client.py):
- LegifranceClient: OAuth2 via PISTE (oauth.piste.gouv.fr), fetches consolidated
  article text via api.piste.gouv.fr/dila/legifrance/lf-engine-app
- Redis cache with 24h TTL (in-memory fallback if no Redis)
- build_filter_prompt() injects live texts into the prompt with explicit priority
  over static descriptions — model uses official Légifrance text, not training memory
- Graceful fallback to static prompt if API is unavailable — filtering never blocks

Option B — weekly sync script (scripts/check_legal_refs.py):
- Reads legal_refs.yaml (11 tracked articles, priority critical/high/medium)
- Fetches current text from Légifrance, diffs against stored baseline
- --init: resolves LEGIARTI IDs on first run
- --check: report only; --update: saves new texts to YAML
- Exit code 2 when changes detected (CI/n8n-compatible)

Supporting changes:
- legal_refs.yaml: tracked articles with code LEGITEXT IDs, priorities, rationale
- requirements.txt: add pyyaml>=6.0.2 and requests>=2.32.0
- .env.example: document PISTE_CLIENT_ID, PISTE_CLIENT_SECRET, LEGIFRANCE_CACHE_TTL
- transparence.tsx: document the Légifrance injection for public transparency
- Fixed portal URL: piste.gouv.fr (not piste.api.gouv.fr — verified June 2026)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 09:19:23 +02:00
parent e8b4bde2f4
commit 67bb0095fa
8 changed files with 764 additions and 2 deletions
+21
View File
@@ -41,6 +41,27 @@ PORT=8080
# HCAPTCHA_SECRET_KEY=votre-cle-secrete-hcaptcha # HCAPTCHA_SECRET_KEY=votre-cle-secrete-hcaptcha
# VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site-hcaptcha # Nécessite rebuild frontend # VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site-hcaptcha # Nécessite rebuild frontend
# ─── API Légifrance — Injection dynamique des textes légaux (optionnel) ───────
# Permet de récupérer les textes des articles légaux en vigueur depuis Légifrance
# et de les injecter dans le prompt de filtrage, garantissant que le modèle IA
# travaille sur les textes officiels consolidés du jour, et non sa mémoire d'entraînement.
#
# Inscription gratuite sur https://piste.gouv.fr
# Créez une application → activez l'API Légifrance → copiez Client ID et Client Secret.
#
# PISTE_CLIENT_ID=votre-client-id
# PISTE_CLIENT_SECRET=votre-client-secret
#
# Durée de cache des textes légaux (Redis, défaut 24h) :
# LEGIFRANCE_CACHE_TTL=86400
#
# Initialisation (première fois, pour résoudre les LEGIARTI IDs) :
# python scripts/check_legal_refs.py --init
# Vérification hebdomadaire des changements :
# python scripts/check_legal_refs.py --check
# Mise à jour du YAML après changement :
# python scripts/check_legal_refs.py --update
# ─── Frontend ───────────────────────────────────────────────────────────────── # ─── Frontend ─────────────────────────────────────────────────────────────────
# URL publique du site (utilisée par le QR code et les exports) # URL publique du site (utilisée par le QR code et les exports)
VITE_APP_URL=https://votredomaine.fr VITE_APP_URL=https://votredomaine.fr
+24 -2
View File
@@ -9,15 +9,33 @@ import json
import os import os
import logging import logging
from openai import OpenAI, BadRequestError from openai import OpenAI, BadRequestError
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT, build_filter_prompt
from legifrance_client import get_legifrance_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MISTRAL_BASE_URL = "https://api.mistral.ai/v1" MISTRAL_BASE_URL = "https://api.mistral.ai/v1"
_LEGAL_REFS_PATH = os.path.join(os.path.dirname(__file__), "legal_refs.yaml")
_client: OpenAI | None = None _client: OpenAI | None = None
def _get_live_legal_texts() -> dict[str, str]:
"""
Récupère les textes consolidés des articles critiques depuis Légifrance (PISTE).
Retourne {} si PISTE n'est pas configuré ou en cas d'erreur — sans bloquer le filtrage.
Les résultats sont mis en cache dans Redis (24h).
"""
try:
lf = get_legifrance_client()
if not lf.available:
return {}
return lf.fetch_tracked_articles(_LEGAL_REFS_PATH)
except Exception:
logger.warning("Légifrance: impossible de récupérer les textes live — mode statique")
return {}
def get_client() -> OpenAI: def get_client() -> OpenAI:
""" """
Supporte deux modes (par ordre de priorité) : Supporte deux modes (par ordre de priorité) :
@@ -58,12 +76,16 @@ def filter_idea(content: str) -> dict:
try: try:
client = get_client() client = get_client()
filter_model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest")) filter_model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest"))
live_texts = _get_live_legal_texts()
system_prompt = build_filter_prompt(live_texts)
if live_texts:
logger.debug("Légifrance: %d article(s) injecté(s) dans le prompt", len(live_texts))
response = client.chat.completions.create( response = client.chat.completions.create(
model=filter_model, model=filter_model,
max_tokens=300, max_tokens=300,
response_format={"type": "json_object"}, response_format={"type": "json_object"},
messages=[ messages=[
{"role": "system", "content": LEGAL_FILTER_PROMPT}, {"role": "system", "content": system_prompt},
{"role": "user", "content": f'Idée soumise : "{content}"'}, {"role": "user", "content": f'Idée soumise : "{content}"'},
], ],
) )
+50
View File
@@ -19,7 +19,57 @@ Sources françaises :
- Code pénal français (partie législative et réglementaire) - Code pénal français (partie législative et réglementaire)
- Loi sur la liberté de la presse du 29 juillet 1881 - Loi sur la liberté de la presse du 29 juillet 1881
- Code civil français - Code civil français
Injection dynamique :
- Les textes consolidés des articles critiques sont récupérés en temps réel
depuis l'API Légifrance (PISTE) et injectés dans le prompt via build_filter_prompt().
- Le suivi des changements est assuré par scripts/check_legal_refs.py.
- Fichier de configuration : legal_refs.yaml (articles surveillés, LEGIARTI IDs).
""" """
from __future__ import annotations
import logging
from datetime import date
logger = logging.getLogger(__name__)
_CRITERIA_ANCHOR = (
"═══════════════════════════════════════════════════════════════════════════════\n"
"CRITÈRES D'ACCEPTATION"
)
def build_filter_prompt(live_texts: dict[str, str] | None = None) -> str:
"""
Construit le prompt de filtrage en injectant les textes légaux consolidés
récupérés depuis Légifrance.
live_texts : {ref_article: texte_en_vigueur} issu de LegifranceClient.
Si vide ou None, retourne LEGAL_FILTER_PROMPT statique.
Les textes injectés ont PRIORITÉ ABSOLUE sur les descriptions statiques
du cadre légal. Ils sont placés juste avant les critères d'acceptation.
"""
if not live_texts:
return LEGAL_FILTER_PROMPT
today = date.today().isoformat()
block = (
"═══════════════════════════════════════════════════════════════════════════════\n"
f"TEXTES LÉGAUX EN VIGUEUR — SOURCE LÉGIFRANCE ({today})\n"
"═══════════════════════════════════════════════════════════════════════════════\n\n"
"Ces extraits sont récupérés depuis l'API officielle Légifrance (PISTE).\n"
"Ils ont PRIORITÉ ABSOLUE sur les descriptions du cadre ci-dessus.\n\n"
)
for ref, text in live_texts.items():
block += f"[{ref}] — texte consolidé en vigueur :\n{text}\n\n"
if _CRITERIA_ANCHOR in LEGAL_FILTER_PROMPT:
parts = LEGAL_FILTER_PROMPT.split(_CRITERIA_ANCHOR, 1)
return parts[0] + block + _CRITERIA_ANCHOR + parts[1]
logger.warning("build_filter_prompt: ancre CRITÈRES D'ACCEPTATION introuvable — fallback statique")
return LEGAL_FILTER_PROMPT
LEGAL_FILTER_PROMPT = """ LEGAL_FILTER_PROMPT = """
Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne. Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne.
+153
View File
@@ -0,0 +1,153 @@
# Références légales surveillées via API Légifrance (PISTE)
# Ce fichier est la source de vérité pour le suivi des articles.
#
# Mise à jour :
# python scripts/check_legal_refs.py --init # peuple les legiarti_id
# python scripts/check_legal_refs.py --check # rapport des changements
# python scripts/check_legal_refs.py --update # met à jour last_text
#
# Inscription PISTE (gratuite) : https://piste.gouv.fr
meta:
description: "Articles critiques — filtrage éthique La Voix du Peuple"
last_sync: null
source: "https://www.legifrance.gouv.fr"
piste_docs: "https://piste.gouv.fr/documentation"
# LEGITEXT IDs stables des codes sources
codes:
code_penal: LEGITEXT000006070719
loi_1881: LEGITEXT000006070722
lcen_2004: LEGITEXT000005789847
code_civil: LEGITEXT000006070721
code_sante_pub: LEGITEXT000006072665
articles:
# ── PRIORITÉ CRITIQUE — amendés fréquemment ────────────────────────────────
- key: CP-225-1
ref: "Code pénal, Art. 225-1"
code: LEGITEXT000006070719
article_num: "225-1"
legiarti_id: null # peuplé par --init
priority: critical
reason: >
Liste des critères de discrimination — fréquemment élargie.
Dernière modification significative connue : 2021 (ajout activités syndicales).
Surveiller ajouts éventuels liés à SREN 2024 ou lois ultérieures.
last_text: null # peuplé par --update
verified_at: null
- key: L1881-24
ref: "Loi du 29 juillet 1881, Art. 24"
code: LEGITEXT000006070722
article_num: "24"
legiarti_id: null
priority: critical
reason: >
Provocation à la discrimination et à la haine — fréquemment amendé.
L'apologie du terrorisme en a été retirée en 2014 (transférée CP Art. 421-2-5).
Vérifier toute évolution du périmètre des motifs protégés.
last_text: null
verified_at: null
# ── PRIORITÉ HAUTE ─────────────────────────────────────────────────────────
- key: CP-222-33
ref: "Code pénal, Art. 222-33"
code: LEGITEXT000006070719
article_num: "222-33"
legiarti_id: null
priority: high
reason: >
Harcèlement sexuel — amendé en 2018 (loi n° 2018-703, Schiappa).
Définition élargie, possibles évolutions.
last_text: null
verified_at: null
- key: CP-222-33-2-2
ref: "Code pénal, Art. 222-33-2-2"
code: LEGITEXT000006070719
article_num: "222-33-2-2"
legiarti_id: null
priority: high
reason: >
Cyberharcèlement en meute — introduit par loi n° 2018-703 du 3 août 2018.
Surveiller évolutions liées à SREN 2024 (art. 19 et ss).
last_text: null
verified_at: null
- key: CP-421-2-5
ref: "Code pénal, Art. 421-2-5"
code: LEGITEXT000006070719
article_num: "421-2-5"
legiarti_id: null
priority: high
reason: >
Apologie du terrorisme — introduit par loi n° 2014-1353 du 13 novembre 2014
(anciennement Loi 1881 Art. 24 al. 6). Stable depuis, mais à vérifier.
last_text: null
verified_at: null
- key: CP-211-1
ref: "Code pénal, Art. 211-1"
code: LEGITEXT000006070719
article_num: "211-1"
legiarti_id: null
priority: high
reason: "Génocide — texte fondateur, stable depuis l'entrée en vigueur du code (1994)."
last_text: null
verified_at: null
- key: L1881-24bis
ref: "Loi du 29 juillet 1881, Art. 24 bis"
code: LEGITEXT000006070722
article_num: "24 bis"
legiarti_id: null
priority: high
reason: "Négationnisme — loi Gayssot du 13 juillet 1990, stable."
last_text: null
verified_at: null
- key: CP-226-4-1
ref: "Code pénal, Art. 226-4-1"
code: LEGITEXT000006070719
article_num: "226-4-1"
legiarti_id: null
priority: high
reason: "Usurpation d'identité numérique — LOPPSI 2 (2011). Surveiller extensions."
last_text: null
verified_at: null
# ── PRIORITÉ MOYENNE ───────────────────────────────────────────────────────
- key: CP-212-1
ref: "Code pénal, Art. 212-1"
code: LEGITEXT000006070719
article_num: "212-1"
legiarti_id: null
priority: medium
reason: "Crimes contre l'humanité — stable."
last_text: null
verified_at: null
- key: CP-223-13
ref: "Code pénal, Art. 223-13"
code: LEGITEXT000006070719
article_num: "223-13"
legiarti_id: null
priority: medium
reason: "Provocation au suicide — surveiller évolutions liées aux réseaux sociaux."
last_text: null
verified_at: null
- key: L1881-29
ref: "Loi du 29 juillet 1881, Art. 29"
code: LEGITEXT000006070722
article_num: "29"
legiarti_id: null
priority: medium
reason: "Définition de la diffamation — stable."
last_text: null
verified_at: null
+279
View File
@@ -0,0 +1,279 @@
"""
La Voix du Peuple — Client API Légifrance (PISTE)
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Récupère les textes des articles légaux en vigueur via l'API officielle PISTE.
Cache les résultats dans Redis (24h) ou en mémoire si Redis n'est pas configuré.
Prérequis :
Créer un compte sur https://piste.gouv.fr et obtenir
PISTE_CLIENT_ID + PISTE_CLIENT_SECRET (inscription gratuite).
"""
import json
import logging
import os
import time
import requests
logger = logging.getLogger(__name__)
PISTE_AUTH_URL = "https://oauth.piste.gouv.fr/api/oauth/token" # vérifié juin 2026
PISTE_API_BASE = "https://api.piste.gouv.fr/dila/legifrance/lf-engine-app" # vérifié juin 2026
DEFAULT_CACHE_TTL = int(os.environ.get("LEGIFRANCE_CACHE_TTL", 86400)) # 24h
class LegifranceClient:
"""
Client PISTE avec cache Redis (fallback mémoire).
Utilisation :
client = LegifranceClient()
if client.available:
legiarti_id, text = client.search_article("LEGITEXT000006070719", "225-1")
"""
def __init__(self):
self.client_id = os.environ.get("PISTE_CLIENT_ID")
self.client_secret = os.environ.get("PISTE_CLIENT_SECRET")
self._token: str | None = None
self._token_exp: float = 0.0
self._redis = None
self._mem: dict[str, tuple[str, float]] = {}
self._init_redis()
def _init_redis(self):
url = os.environ.get("REDIS_URL")
if url:
try:
import redis as _r
r = _r.from_url(url)
r.ping()
self._redis = r
except Exception:
pass
@property
def available(self) -> bool:
return bool(self.client_id and self.client_secret)
# ── Cache ────────────────────────────────────────────────────────────────
def _get(self, key: str) -> str | None:
if self._redis:
try:
v = self._redis.get(key)
if v:
return v.decode()
except Exception:
pass
entry = self._mem.get(key)
if entry and time.time() < entry[1]:
return entry[0]
return None
def _set(self, key: str, value: str, ttl: int = DEFAULT_CACHE_TTL):
if self._redis:
try:
self._redis.setex(key, ttl, value)
return
except Exception:
pass
self._mem[key] = (value, time.time() + ttl)
# ── Auth ─────────────────────────────────────────────────────────────────
def _token_headers(self) -> dict[str, str]:
if not (self._token and time.time() < self._token_exp):
resp = requests.post(
PISTE_AUTH_URL,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "openid",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
self._token = data["access_token"]
self._token_exp = time.time() + data.get("expires_in", 3600) - 60
return {"Authorization": f"Bearer {self._token}"}
# ── API calls ─────────────────────────────────────────────────────────────
def fetch_article_by_id(self, legiarti_id: str) -> str | None:
"""Récupère le texte brut d'un article via son identifiant LEGIARTI."""
cache_key = f"lgf:art:{legiarti_id}"
if (cached := self._get(cache_key)):
return cached
try:
resp = requests.post(
f"{PISTE_API_BASE}/consult/getArticle",
json={"id": legiarti_id},
headers=self._token_headers(),
timeout=12,
)
resp.raise_for_status()
article = resp.json().get("article", {})
# L'API peut retourner texte brut ou HTML selon la version
text = article.get("texte") or article.get("texteHtml") or ""
text = text.strip()
if text:
self._set(cache_key, text)
return text or None
except Exception:
logger.warning("Légifrance: échec fetch article %s", legiarti_id)
return None
def search_article(
self, code_legitext: str, article_num: str
) -> tuple[str | None, str | None]:
"""
Cherche un article par code LEGITEXT + numéro d'article.
Retourne (legiarti_id, texte) ou (None, None).
Le format article_num accepté : "225-1", "24 bis", "222-33-2-2", etc.
La recherche tente plusieurs variantes pour maximiser les chances de match.
"""
cache_key = f"lgf:search:{code_legitext}:{article_num}"
if (cached := self._get(cache_key)):
data = json.loads(cached)
return data.get("id"), data.get("text")
try:
headers = self._token_headers()
# Variantes du numéro d'article pour la recherche
variants = [article_num]
if not article_num[0].isalpha():
variants += [f"L{article_num}", f"R{article_num}"]
for variant in variants:
result = self._search_variant(code_legitext, variant, headers)
if result:
legiarti_id, text = result
if legiarti_id and text:
self._set(
cache_key,
json.dumps({"id": legiarti_id, "text": text}),
)
return legiarti_id, text
return None, None
except Exception:
logger.warning(
"Légifrance: échec search %s %s", code_legitext, article_num
)
return None, None
def _search_variant(
self, code_legitext: str, article_num: str, headers: dict
) -> tuple[str, str] | None:
"""Tente une recherche avec un numéro d'article particulier."""
resp = requests.post(
f"{PISTE_API_BASE}/search",
json={
"recherche": {
"champs": [
{
"typeChamp": "NUM_ARTICLE",
"criteres": [
{"typeRecherche": "EXACTE", "valeur": article_num}
],
"operateur": "ET",
}
],
"filtres": [
{"facette": "CODE_ID", "valeurs": [code_legitext]},
{
"facette": "TEXT_LEGAL_STATUS",
"valeurs": ["VIGUEUR"],
},
],
"pageNumber": 1,
"pageSize": 3,
"operateur": "ET",
"sort": "PERTINENCE",
"typePagination": "DEFAUT",
},
"fond": "CODE_DATE",
},
headers=headers,
timeout=15,
)
resp.raise_for_status()
payload = resp.json()
# L'API PISTE peut envoyer les résultats sous différentes structures
results = payload.get("results", [])
if not results:
return None
first = results[0]
# Cas 1 : résultat direct avec id + texte
legiarti_id = first.get("id")
text = first.get("texte") or first.get("extract") or ""
# Cas 2 : résultat imbriqué (sections → articles)
if not legiarti_id:
for section in first.get("sections", []):
for art in section.get("articles", []):
legiarti_id = art.get("id")
text = art.get("texte") or ""
if legiarti_id:
break
if legiarti_id:
break
if not legiarti_id:
return None
# Si le texte est vide dans les résultats, on le récupère par ID
if not text.strip():
text = self.fetch_article_by_id(legiarti_id) or ""
return (legiarti_id, text.strip()) if text.strip() else None
def fetch_tracked_articles(self, refs_path: str) -> dict[str, str]:
"""
Charge legal_refs.yaml et récupère les textes des articles critical/high.
Retourne {ref: texte} pour injection dans le prompt.
"""
if not self.available:
return {}
try:
import yaml # pyyaml
with open(refs_path) as f:
refs = yaml.safe_load(f)
except Exception:
logger.warning("Légifrance: impossible de charger %s", refs_path)
return {}
live: dict[str, str] = {}
for article in refs.get("articles", []):
if article.get("priority") not in ("critical", "high"):
continue
ref = article["ref"]
legiarti_id = article.get("legiarti_id")
if legiarti_id:
text = self.fetch_article_by_id(legiarti_id)
else:
_, text = self.search_article(article["code"], article["article_num"])
if text:
live[ref] = text
return live
# Singleton : un client par worker Gunicorn, cache partagé via Redis
_client: LegifranceClient | None = None
def get_legifrance_client() -> LegifranceClient:
global _client
if _client is None:
_client = LegifranceClient()
return _client
+2
View File
@@ -6,4 +6,6 @@ gunicorn>=23.0.0
openai>=1.77.0 openai>=1.77.0
psycopg2-binary>=2.9.10 psycopg2-binary>=2.9.10
python-dotenv>=1.0.1 python-dotenv>=1.0.1
pyyaml>=6.0.2
redis>=5.0.0 redis>=5.0.0
requests>=2.32.0
@@ -86,6 +86,23 @@ export default function Transparence() {
</p> </p>
</div> </div>
<div className="bg-muted/40 border border-border/50 p-5 space-y-2">
<p className="font-mono text-xs font-semibold uppercase tracking-widest text-primary">Sources légales — Légifrance en temps réel</p>
<p className="text-foreground/90 text-sm leading-relaxed">
Les textes des articles légaux critiques (Art. 225-1, 24 Loi 1881, Art. 421-2-5…) sont
récupérés en temps réel depuis <strong>l'API officielle Légifrance (PISTE)</strong> et injectés
dans le prompt avant chaque analyse. Le modèle travaille ainsi sur le texte
consolidé du jour, pas sur sa mémoire d'entraînement figée.
</p>
<p className="text-foreground/90 text-sm leading-relaxed">
Les textes sont mis en cache 24 h. Un script de surveillance hebdomadaire
(<code className="bg-muted px-1 py-0.5 rounded text-xs">scripts/check_legal_refs.py</code>) détecte
tout changement législatif et alerte en cas de modification. Si l'API Légifrance
est indisponible, le système bascule automatiquement sur le cadre légal statique
intégré au prompt le filtrage continue sans interruption.
</p>
</div>
<div> <div>
<p className="font-semibold mb-2">Ce que le filtre vérifie</p> <p className="font-semibold mb-2">Ce que le filtre vérifie</p>
<p className="text-foreground/80 text-sm leading-relaxed mb-3"> <p className="text-foreground/80 text-sm leading-relaxed mb-3">
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""
La Voix du Peuple — Vérification des références légales via Légifrance (PISTE)
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Usage :
python scripts/check_legal_refs.py --init Peuple les legiarti_id (première fois)
python scripts/check_legal_refs.py --check Rapport des changements (sans modifier)
python scripts/check_legal_refs.py --update Met à jour last_text dans le YAML
Codes de sortie :
0 Tout est à jour
1 Erreur (API, fichier, credentials)
2 Changements détectés (utile pour intégration CI ou n8n webhook)
Nécessite PISTE_CLIENT_ID et PISTE_CLIENT_SECRET dans .env
Inscription gratuite : https://piste.gouv.fr
"""
import argparse
import difflib
import os
import sys
from datetime import UTC, datetime
# Ajoute flask-api au path pour importer legifrance_client
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO_ROOT, "artifacts", "flask-api"))
from dotenv import load_dotenv
load_dotenv(os.path.join(REPO_ROOT, ".env"))
try:
import yaml
except ImportError:
print("❌ pyyaml manquant — installez : pip install pyyaml", file=sys.stderr)
sys.exit(1)
from legifrance_client import LegifranceClient
REFS_PATH = os.path.join(REPO_ROOT, "artifacts", "flask-api", "legal_refs.yaml")
PRIORITY_LABELS = {
"critical": "🔴 CRITIQUE",
"high": "🟠 HAUTE",
"medium": "🟡 MOYENNE",
}
def load_refs() -> dict:
with open(REFS_PATH, encoding="utf-8") as f:
return yaml.safe_load(f)
def save_refs(refs: dict):
with open(REFS_PATH, "w", encoding="utf-8") as f:
yaml.dump(refs, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
def cmd_init(client: LegifranceClient, refs: dict):
"""Peuple les legiarti_id manquants dans le YAML."""
print("Résolution des LEGIARTI IDs manquants via Légifrance…\n")
updated = 0
for article in refs.get("articles", []):
if article.get("legiarti_id"):
print(f"{article['ref']} — ID déjà connu")
continue
new_id, text = client.search_article(article["code"], article["article_num"])
if new_id:
article["legiarti_id"] = new_id
updated += 1
snippet = (text or "")[:120].replace("\n", " ")
print(f"{article['ref']}")
print(f" ID : {new_id}")
print(f" Ext : {snippet}")
else:
print(f" ⚠️ {article['ref']} — introuvable (vérifier code/num)")
if updated:
refs["meta"]["last_sync"] = datetime.now(UTC).isoformat()
save_refs(refs)
print(f"\n{updated} ID(s) sauvegardé(s) dans legal_refs.yaml")
else:
print("\nAucun nouvel ID trouvé.")
def cmd_check(client: LegifranceClient, refs: dict, update: bool) -> int:
"""Compare les textes actuels avec ceux stockés. Retourne le nombre de changements."""
today = datetime.now(UTC).date().isoformat()
changes = 0
for article in refs.get("articles", []):
key = article["key"]
ref = article["ref"]
priority = PRIORITY_LABELS.get(article.get("priority", ""), "")
legiarti_id = article.get("legiarti_id")
last_text = (article.get("last_text") or "").strip()
print(f"\n{'' * 62}")
print(f"{priority} {ref} [{key}]")
if legiarti_id:
live_text = client.fetch_article_by_id(legiarti_id) or ""
else:
new_id, live_text = client.search_article(article["code"], article["article_num"])
live_text = live_text or ""
if new_id and update and not legiarti_id:
article["legiarti_id"] = new_id
print(f" ↳ LEGIARTI ID résolu : {new_id}")
live_text = live_text.strip()
if not live_text:
print(" ⚠️ Texte non récupéré — API indisponible ou article introuvable")
continue
if not last_text:
print(" ️ Premier enregistrement (pas de baseline)")
print(f" Extrait : {live_text[:200].replace(chr(10), ' ')}")
changes += 1
if update:
article["last_text"] = live_text
article["verified_at"] = today
elif live_text == last_text:
print(" ✅ Inchangé")
else:
changes += 1
print(" ⚠️ CHANGEMENT DÉTECTÉ")
diff = list(
difflib.unified_diff(
last_text.splitlines(),
live_text.splitlines(),
fromfile="ancienne version",
tofile="version Légifrance actuelle",
lineterm="",
n=2,
)
)
for line in diff[:40]:
prefix = " "
if line.startswith("+"):
prefix = " +"
elif line.startswith("-"):
prefix = " -"
print(f"{prefix}{line}")
if len(diff) > 40:
print(f" … ({len(diff) - 40} lignes supplémentaires)")
if update:
article["last_text"] = live_text
article["verified_at"] = today
if update and changes:
refs["meta"]["last_sync"] = datetime.now(UTC).isoformat()
save_refs(refs)
print(f"\n✅ legal_refs.yaml mis à jour ({changes} article(s) modifié(s))")
return changes
def main():
parser = argparse.ArgumentParser(
description="Vérification des références légales via API Légifrance (PISTE)"
)
parser.add_argument(
"--init",
action="store_true",
help="Peuple les legiarti_id manquants (première exécution)",
)
parser.add_argument(
"--check",
action="store_true",
help="Rapport des changements — ne modifie pas le YAML",
)
parser.add_argument(
"--update",
action="store_true",
help="Met à jour last_text dans legal_refs.yaml",
)
args = parser.parse_args()
if not (args.init or args.check or args.update):
parser.print_help()
sys.exit(0)
client = LegifranceClient()
if not client.available:
print(
"❌ PISTE_CLIENT_ID et/ou PISTE_CLIENT_SECRET non définis dans .env\n"
" Inscription gratuite : https://piste.gouv.fr\n"
" Ajoutez ensuite dans .env :\n"
" PISTE_CLIENT_ID=...\n"
" PISTE_CLIENT_SECRET=...",
file=sys.stderr,
)
sys.exit(1)
refs = load_refs()
if args.init:
cmd_init(client, refs)
return
changes = cmd_check(client, refs, update=args.update)
print(f"\n{'' * 62}")
if changes:
print(f"⚠️ {changes} article(s) à vérifier / mis à jour.")
if not args.update:
print(" Relancez avec --update pour sauvegarder les textes actuels.")
sys.exit(2)
else:
print("✅ Toutes les références sont à jour.")
sys.exit(0)
if __name__ == "__main__":
main()