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