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:
@@ -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
|
||||||
|
|||||||
@@ -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}"'},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user