Files
la-voix-du-peuple/artifacts/flask-api/ai_agent.py
T
billisdead 67bb0095fa 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>
2026-06-24 09:19:23 +02:00

189 lines
7.6 KiB
Python

"""
La Voix du Peuple — Agent IA
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Agent IA pour le filtrage éthique et la synthèse démocratique.
Supporte Mistral AI (par défaut) et tout fournisseur compatible OpenAI.
"""
import json
import os
import logging
from openai import OpenAI, BadRequestError
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é) :
1. Mistral AI : MISTRAL_API_KEY (+ MISTRAL_BASE_URL optionnel)
2. OpenAI-compatible : OPENAI_API_KEY (+ OPENAI_BASE_URL optionnel)
"""
global _client
if _client is None:
mistral_key = os.environ.get("MISTRAL_API_KEY")
mistral_base = os.environ.get("MISTRAL_BASE_URL", MISTRAL_BASE_URL)
std_key = os.environ.get("OPENAI_API_KEY")
std_base = os.environ.get("OPENAI_BASE_URL")
if mistral_key:
logger.info("Utilisation de l'API Mistral AI (%s)", mistral_base)
_client = OpenAI(base_url=mistral_base, api_key=mistral_key)
elif std_key:
logger.info("Utilisation d'une API compatible OpenAI")
kwargs: dict = {"api_key": std_key}
if std_base:
kwargs["base_url"] = std_base
_client = OpenAI(**kwargs)
else:
raise RuntimeError(
"Aucune clé IA configurée. "
"Définissez MISTRAL_API_KEY (recommandé) ou OPENAI_API_KEY dans le fichier .env."
)
return _client
def filter_idea(content: str) -> dict:
"""
Filtre une idée selon le cadre légal international des droits humains
et le droit pénal français (Code pénal, Loi du 29 juillet 1881, LCEN,
Loi SREN 2024, RGPD, Code de la santé publique).
Retourne : {"accepted": bool, "reason"?: str, "legal_basis"?: str}
"""
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": system_prompt},
{"role": "user", "content": f'Idée soumise : "{content}"'},
],
)
raw = (
response.choices[0].message.content
or '{"accepted": false, "reason": "Contenu non conforme aux principes démocratiques."}'
)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
raw = raw.strip()
start = raw.find("{")
end = raw.rfind("}") + 1
if start != -1 and end > start:
raw = raw[start:end]
result = json.loads(raw)
return result
except json.JSONDecodeError:
logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in locals() else 'N/A')
return {"accepted": False, "reason": "Erreur interne de filtrage"}
except BadRequestError as e:
if "content_filter" in str(e) or "content management policy" in str(e):
logger.warning("Contenu bloqué par le filtre de sécurité du proxy IA — rejet automatique")
return {
"accepted": False,
"reason": (
"Ce contenu a été automatiquement bloqué car il contient des propos "
"haineux ou violents graves, contraires à la dignité humaine."
),
"legal_basis": (
"DUDH Art. 1 (dignité humaine), DUDH Art. 20 (interdiction de la haine), "
"PIDCP Art. 20, CEDH Art. 17 (abus de droit)"
),
}
logger.exception("Erreur API lors du filtrage")
return {"accepted": False, "reason": "Service temporairement indisponible"}
except Exception:
logger.exception("Erreur lors du filtrage de l'idée")
return {"accepted": False, "reason": "Service temporairement indisponible"}
def check_existing_law(content: str) -> str:
"""
Vérifie si une proposition citoyenne est déjà couverte par une loi
française ou un règlement européen en vigueur.
Retourne une phrase informative (non bloquante).
"""
try:
client = get_client()
model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest"))
response = client.chat.completions.create(
model=model,
max_tokens=200,
messages=[
{"role": "system", "content": LAW_CHECK_PROMPT},
{"role": "user", "content": f'Proposition : "{content}"'},
],
)
return (response.choices[0].message.content or "").strip()
except Exception:
logger.exception("Erreur lors de la vérification du cadre légal existant")
return ""
def synthesize_ideas(ideas: list[str]) -> str:
"""
Synthétise une liste d'idées acceptées en un texte collectif
ancré dans les valeurs démocratiques et les droits humains.
"""
if not ideas:
return (
"Aucune idée n'a encore été soumise. "
"Soyez le premier à partager votre vision pour une société meilleure, "
"fondée sur la Déclaration universelle des droits de l'homme."
)
try:
client = get_client()
synthesis_model = os.environ.get("SYNTHESIS_MODEL", os.environ.get("OPENAI_SYNTHESIS_MODEL", "mistral-large-latest"))
ideas_text = "\n".join(f"{i + 1}. {idea}" for i, idea in enumerate(ideas))
response = client.chat.completions.create(
model=synthesis_model,
max_tokens=1200,
messages=[
{"role": "system", "content": SYNTHESIS_PROMPT},
{
"role": "user",
"content": (
f"Voici les {len(ideas)} idée(s) citoyenne(s) validées :\n\n"
f"{ideas_text}\n\n"
"Rédige La Voix du Peuple."
),
},
],
)
return response.choices[0].message.content or "Synthèse en cours..."
except Exception:
logger.exception("Erreur lors de la synthèse des idées")
return "La synthèse est temporairement indisponible. Vos idées ont bien été enregistrées."