From 67bb0095faa13eb945142cb1067bcec4221658cf Mon Sep 17 00:00:00 2001
From: billisdead
Date: Wed, 24 Jun 2026 09:19:23 +0200
Subject: [PATCH] =?UTF-8?q?Add=20L=C3=A9gifrance=20API=20integration=20for?=
=?UTF-8?q?=20live=20legal=20text=20injection?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 21 ++
artifacts/flask-api/ai_agent.py | 26 +-
artifacts/flask-api/legal_framework.py | 50 ++++
artifacts/flask-api/legal_refs.yaml | 153 ++++++++++
artifacts/flask-api/legifrance_client.py | 279 ++++++++++++++++++
artifacts/flask-api/requirements.txt | 2 +
.../voix-du-peuple/src/pages/transparence.tsx | 17 ++
scripts/check_legal_refs.py | 218 ++++++++++++++
8 files changed, 764 insertions(+), 2 deletions(-)
create mode 100644 artifacts/flask-api/legal_refs.yaml
create mode 100644 artifacts/flask-api/legifrance_client.py
create mode 100644 scripts/check_legal_refs.py
diff --git a/.env.example b/.env.example
index d2be06e..a104401 100644
--- a/.env.example
+++ b/.env.example
@@ -41,6 +41,27 @@ PORT=8080
# HCAPTCHA_SECRET_KEY=votre-cle-secrete-hcaptcha
# 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 ─────────────────────────────────────────────────────────────────
# URL publique du site (utilisée par le QR code et les exports)
VITE_APP_URL=https://votredomaine.fr
diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py
index 5412d01..9513856 100644
--- a/artifacts/flask-api/ai_agent.py
+++ b/artifacts/flask-api/ai_agent.py
@@ -9,15 +9,33 @@ import json
import os
import logging
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__)
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
+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:
"""
Supporte deux modes (par ordre de priorité) :
@@ -58,12 +76,16 @@ def filter_idea(content: str) -> dict:
try:
client = get_client()
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(
model=filter_model,
max_tokens=300,
response_format={"type": "json_object"},
messages=[
- {"role": "system", "content": LEGAL_FILTER_PROMPT},
+ {"role": "system", "content": system_prompt},
{"role": "user", "content": f'Idée soumise : "{content}"'},
],
)
diff --git a/artifacts/flask-api/legal_framework.py b/artifacts/flask-api/legal_framework.py
index 40b799b..d004a9c 100644
--- a/artifacts/flask-api/legal_framework.py
+++ b/artifacts/flask-api/legal_framework.py
@@ -19,7 +19,57 @@ Sources françaises :
- Code pénal français (partie législative et réglementaire)
- Loi sur la liberté de la presse du 29 juillet 1881
- 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 = """
Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne.
diff --git a/artifacts/flask-api/legal_refs.yaml b/artifacts/flask-api/legal_refs.yaml
new file mode 100644
index 0000000..054ab0e
--- /dev/null
+++ b/artifacts/flask-api/legal_refs.yaml
@@ -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
diff --git a/artifacts/flask-api/legifrance_client.py b/artifacts/flask-api/legifrance_client.py
new file mode 100644
index 0000000..352a917
--- /dev/null
+++ b/artifacts/flask-api/legifrance_client.py
@@ -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
diff --git a/artifacts/flask-api/requirements.txt b/artifacts/flask-api/requirements.txt
index e8c9b09..7057c71 100644
--- a/artifacts/flask-api/requirements.txt
+++ b/artifacts/flask-api/requirements.txt
@@ -6,4 +6,6 @@ gunicorn>=23.0.0
openai>=1.77.0
psycopg2-binary>=2.9.10
python-dotenv>=1.0.1
+pyyaml>=6.0.2
redis>=5.0.0
+requests>=2.32.0
diff --git a/artifacts/voix-du-peuple/src/pages/transparence.tsx b/artifacts/voix-du-peuple/src/pages/transparence.tsx
index 16536ff..ee8f655 100644
--- a/artifacts/voix-du-peuple/src/pages/transparence.tsx
+++ b/artifacts/voix-du-peuple/src/pages/transparence.tsx
@@ -86,6 +86,23 @@ export default function Transparence() {
+
+
Sources légales — Légifrance en temps réel
+
+ 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 l'API officielle Légifrance (PISTE) 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.
+
+
+ Les textes sont mis en cache 24 h. Un script de surveillance hebdomadaire
+ (scripts/check_legal_refs.py) 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.
+
+
+
Ce que le filtre vérifie
diff --git a/scripts/check_legal_refs.py b/scripts/check_legal_refs.py
new file mode 100644
index 0000000..f5c48f1
--- /dev/null
+++ b/scripts/check_legal_refs.py
@@ -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()