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:
billisdeadandClaude Sonnet 4.6 committed 2026-06-24 09:19:23 +02:00
1 parent e8b4bde2f4
commit 67bb0095fa
8 files changed
+764 -2

No files matched your search

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