Phase 5 : collecteurs officiels et run de veille — dry-run contre sources réelles
Collecteurs, chacun isolé et tolérant à l'échec : - legifrance : OAuth PISTE validé en production. Deux pièges vérifiés sur le compte réel — seul le couple « Client ID / Client secret » ouvre un jeton (la clé d'API donne invalid_client), et les identifiants de production ne passent pas en bac à sable. - senat : liste chronologique des lois promulguées. L'URL du §5.1 (senat.fr/lois/index.html) est morte ; l'équivalent fonctionnel est /dossiers-legislatifs/lois-promulguees.html — 31 lois 2026 relevées. - conseil_constitutionnel : sans le paramètre ?id=32246 la page des affaires en instance ne rend que des QPC et aucune affaire DC. Les colonnes DC et QPC diffèrent : lecture par en-tête, jamais par position. Rapprochement à trois issues — numéro officiel, similarité de titre, et une zone de signalement où le pipeline s'abstient plutôt que de fusionner. make update-dry contre les sources réelles : aucun faux positif sur les 52 textes du corpus, et trois apports que le rapport n'avait pas — le numéro d'affaire 2026-907 DC de la programmation militaire, la date de saisine du 16 juillet pour l'aide à mourir, les décisions du 23 juillet sur 908 et 909. Une asymétrie de sources est documentée par un test plutôt que corrigée en douce : le Sénat ne qualifie pas la loi 2026-650 d'organique, Légifrance si. Le collecteur rapporte ce que sa source dit ; le champ n'est pas comparé. Fixtures HTML figées : aucun test ne touche au réseau. 156 tests, 91 % de couverture (chaque module au-dessus du seuil de 70 %). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9fc8f3f67
commit
feb5544599
@@ -5,12 +5,22 @@
|
|||||||
|
|
||||||
# ── API Légifrance (PISTE) ───────────────────────────────────────────────────
|
# ── API Légifrance (PISTE) ───────────────────────────────────────────────────
|
||||||
# Compte : https://piste.gouv.fr — application abonnée à l'API « Légifrance ».
|
# Compte : https://piste.gouv.fr — application abonnée à l'API « Légifrance ».
|
||||||
|
#
|
||||||
|
# ATTENTION : une application PISTE expose DEUX couples de valeurs, et un seul
|
||||||
|
# fonctionne ici. Il faut le couple OAuth (« Client ID » / « Client secret »),
|
||||||
|
# pas la clé d'API (« API key » / « API key secret »), qui donne un
|
||||||
|
# `invalid_client` au moment d'obtenir le jeton.
|
||||||
|
#
|
||||||
# Sans ces deux valeurs le pipeline fonctionne en mode dégradé documenté
|
# Sans ces deux valeurs le pipeline fonctionne en mode dégradé documenté
|
||||||
# (repli sur le scraping léger AN / Sénat / Conseil constitutionnel).
|
# (repli sur le scraping léger AN / Sénat / Conseil constitutionnel).
|
||||||
LEGIFRANCE_CLIENT_ID=
|
LEGIFRANCE_CLIENT_ID=
|
||||||
LEGIFRANCE_CLIENT_SECRET=
|
LEGIFRANCE_CLIENT_SECRET=
|
||||||
# `sandbox` pour l'environnement de test PISTE, `prod` pour la production.
|
# `sandbox` pour l'environnement de test PISTE, `prod` pour la production.
|
||||||
|
# Vérifié le 25 juillet 2026 : les identifiants de production ne sont PAS
|
||||||
|
# acceptés par le bac à sable, qui exige une application distincte.
|
||||||
LEGIFRANCE_ENV=prod
|
LEGIFRANCE_ENV=prod
|
||||||
|
# Clé d'API PISTE. Facultative : l'API Légifrance se contente du jeton OAuth.
|
||||||
|
LEGIFRANCE_API_KEY=
|
||||||
|
|
||||||
# ── Comportement du pipeline ─────────────────────────────────────────────────
|
# ── Comportement du pipeline ─────────────────────────────────────────────────
|
||||||
# Chemin de la base SQLite (relatif à la racine du dépôt).
|
# Chemin de la base SQLite (relatif à la racine du dépôt).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Collecteurs des sources officielles.
|
||||||
|
|
||||||
|
Chaque collecteur est isolé : il déclare ce qu'il sait faire, tente sa collecte,
|
||||||
|
et rend un `ResultatCollecte` — jamais une exception. Un collecteur en panne ne
|
||||||
|
doit pas empêcher les autres de tourner ni faire échouer le run.
|
||||||
|
|
||||||
|
Tous passent par le client HTTP commun (`http.py`), qui impose un user-agent
|
||||||
|
honnête avec adresse de contact, un délai minimal entre deux requêtes vers un
|
||||||
|
même hôte, et un cache disque. Aucun collecteur n'ouvre de connexion en direct.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
|
||||||
|
|
||||||
|
__all__ = ["Collecteur", "ResultatCollecte", "TexteCollecte"]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Contrat commun à tous les collecteurs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pipeline.journal import logger
|
||||||
|
from pipeline.modeles import DecisionCC, Evenement, Source, Statut, TypeTexte
|
||||||
|
|
||||||
|
log = logger("collecteur")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TexteCollecte:
|
||||||
|
"""Un texte observé sur une source officielle.
|
||||||
|
|
||||||
|
Volontairement plus pauvre qu'un `Texte` : un collecteur rapporte ce qu'il
|
||||||
|
voit, il ne classe pas. La classification et la cotation Guadeloupe sont
|
||||||
|
faites ensuite, et signalées comme automatiques.
|
||||||
|
"""
|
||||||
|
|
||||||
|
titre: str
|
||||||
|
source_url: str
|
||||||
|
collecteur: str
|
||||||
|
|
||||||
|
numero_officiel: str | None = None
|
||||||
|
type: TypeTexte | None = None
|
||||||
|
statut: Statut | None = None
|
||||||
|
date_promulgation: date | None = None
|
||||||
|
date_adoption: date | None = None
|
||||||
|
date_publication_jo: date | None = None
|
||||||
|
identifiant_externe: str | None = None # cid Légifrance, dossier AN…
|
||||||
|
resume: str | None = None
|
||||||
|
|
||||||
|
evenements: list[Evenement] = field(default_factory=list)
|
||||||
|
decisions_cc: list[DecisionCC] = field(default_factory=list)
|
||||||
|
sources: list[Source] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ResultatCollecte:
|
||||||
|
"""Ce qu'un collecteur rapporte d'un passage."""
|
||||||
|
|
||||||
|
collecteur: str
|
||||||
|
textes: list[TexteCollecte] = field(default_factory=list)
|
||||||
|
decisions_cc: list[DecisionCC] = field(default_factory=list)
|
||||||
|
erreurs: list[str] = field(default_factory=list)
|
||||||
|
duree_s: float = 0.0
|
||||||
|
ignore: bool = False
|
||||||
|
motif_ignore: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a_reussi(self) -> bool:
|
||||||
|
return not self.erreurs and not self.ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Collecteur(ABC):
|
||||||
|
"""Un collecteur de source officielle.
|
||||||
|
|
||||||
|
Les implémentations ne lèvent jamais : `collecter()` capture ses propres
|
||||||
|
erreurs et les range dans le résultat. C'est ce qui permet au run complet de
|
||||||
|
se poursuivre quand une source est indisponible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
nom: str = "inconnu"
|
||||||
|
|
||||||
|
def est_disponible(self) -> tuple[bool, str | None]:
|
||||||
|
"""Indique si le collecteur peut travailler, et sinon pourquoi.
|
||||||
|
|
||||||
|
Sert à documenter les dégradations : un collecteur privé de clé d'API
|
||||||
|
doit le dire clairement plutôt que d'échouer silencieusement.
|
||||||
|
"""
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _collecter(self) -> ResultatCollecte:
|
||||||
|
"""Collecte effective, susceptible de lever."""
|
||||||
|
|
||||||
|
def collecter(self) -> ResultatCollecte:
|
||||||
|
depart = time.monotonic()
|
||||||
|
disponible, motif = self.est_disponible()
|
||||||
|
|
||||||
|
if not disponible:
|
||||||
|
log.info("collecteur ignoré", collecteur=self.nom, motif=motif)
|
||||||
|
return ResultatCollecte(
|
||||||
|
collecteur=self.nom, ignore=True, motif_ignore=motif,
|
||||||
|
duree_s=time.monotonic() - depart,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resultat = self._collecter()
|
||||||
|
except Exception as erreur: # noqa: BLE001 — un collecteur ne fait pas tomber le run
|
||||||
|
log.warning("collecteur en échec", collecteur=self.nom, erreur=str(erreur))
|
||||||
|
resultat = ResultatCollecte(collecteur=self.nom, erreurs=[str(erreur)])
|
||||||
|
|
||||||
|
resultat.duree_s = time.monotonic() - depart
|
||||||
|
log.info(
|
||||||
|
"collecte terminée",
|
||||||
|
collecteur=self.nom,
|
||||||
|
textes=len(resultat.textes),
|
||||||
|
decisions=len(resultat.decisions_cc),
|
||||||
|
erreurs=len(resultat.erreurs),
|
||||||
|
duree_s=round(resultat.duree_s, 2),
|
||||||
|
)
|
||||||
|
return resultat
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Collecteur du Conseil constitutionnel.
|
||||||
|
|
||||||
|
Deux pages, deux usages :
|
||||||
|
|
||||||
|
- **Affaires en instance**, filtrées sur les saisines DC par le paramètre
|
||||||
|
`?id=32246` — sans lui, la page ne rend que des questions prioritaires de
|
||||||
|
constitutionnalité et aucune affaire DC. C'est là que se lit le sort des
|
||||||
|
textes adoptés mais non promulgués.
|
||||||
|
- **Décisions rendues**, pour détecter le passage d'une affaire en instance à
|
||||||
|
une décision, et donc le déblocage d'une promulgation.
|
||||||
|
|
||||||
|
Vérifié le 25 juillet 2026 : la page des affaires en instance rendait les sept
|
||||||
|
affaires DC attendues par le corpus, et une de plus que lui — l'affaire
|
||||||
|
n° 2026-907 DC sur la programmation militaire, que le rapport mentionnait sans
|
||||||
|
numéro. C'est précisément ce qu'un pipeline de veille doit rattraper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from selectolax.parser import HTMLParser, Node
|
||||||
|
|
||||||
|
from pipeline.collecteurs.base import Collecteur, ResultatCollecte
|
||||||
|
from pipeline.collecteurs.http import ClientHttp
|
||||||
|
from pipeline.journal import logger
|
||||||
|
from pipeline.modeles import DecisionCC, ResultatCC
|
||||||
|
from pipeline.parseurs.dates_fr import lire_date, sans_accents
|
||||||
|
|
||||||
|
log = logger("collecteur.conseil_constitutionnel")
|
||||||
|
|
||||||
|
BASE = "https://www.conseil-constitutionnel.fr"
|
||||||
|
URL_AFFAIRES_DC = f"{BASE}/decisions/affaires-en-instances?id=32246"
|
||||||
|
URL_DECISIONS = f"{BASE}/decisions"
|
||||||
|
|
||||||
|
_AFFAIRE = re.compile(r"\b(\d{4}-\d{1,4})\s*DC\b")
|
||||||
|
_DECISION_TITRE = re.compile(
|
||||||
|
r"D[ée]cision\s+n°\s*(\d{4}-\d{1,4})\s*DC\s+du\s+([^-–—]+?)\s{2,}(.*)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Formulations employées par le Conseil dans ses intitulés de décision, testées
|
||||||
|
# du plus spécifique au plus général. L'ordre compte : « non conformité
|
||||||
|
# partielle » contient « conformité », et « conformité » ne contient pas
|
||||||
|
# « conforme » — c'est bien « Conformité » et « Conformité - réserve » que la
|
||||||
|
# page publie, pas « conforme ».
|
||||||
|
_RESULTATS = (
|
||||||
|
("non conformite partielle", ResultatCC.NON_CONFORMITE_PARTIELLE),
|
||||||
|
("non conformite totale", ResultatCC.NON_CONFORMITE_TOTALE),
|
||||||
|
("non conformite", ResultatCC.NON_CONFORMITE_TOTALE),
|
||||||
|
("non conforme", ResultatCC.NON_CONFORMITE_TOTALE),
|
||||||
|
("conformite - reserve", ResultatCC.CONFORME_AVEC_RESERVES),
|
||||||
|
("conformite reserve", ResultatCC.CONFORME_AVEC_RESERVES),
|
||||||
|
("conformite avec reserve", ResultatCC.CONFORME_AVEC_RESERVES),
|
||||||
|
("conforme avec reserve", ResultatCC.CONFORME_AVEC_RESERVES),
|
||||||
|
("conformite", ResultatCC.CONFORME),
|
||||||
|
("conforme", ResultatCC.CONFORME),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Le tableau des affaires DC n'a pas les mêmes colonnes que celui des QPC :
|
||||||
|
# les positions ne sont donc pas fiables, seuls les en-têtes le sont.
|
||||||
|
_COLONNES = {
|
||||||
|
"affaire": ("affaire",),
|
||||||
|
"disposition": ("disposition",),
|
||||||
|
"saisissants": ("auteur de la saisine", "auteur"),
|
||||||
|
"date_saisine": ("date de la saisine",),
|
||||||
|
"date_decision": ("date de lecture de la decision", "decision rendue le"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CollecteurConseilConstitutionnel(Collecteur):
|
||||||
|
nom = "conseil_constitutionnel"
|
||||||
|
|
||||||
|
def __init__(self, client: ClientHttp) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def _collecter(self) -> ResultatCollecte:
|
||||||
|
resultat = ResultatCollecte(collecteur=self.nom)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resultat.decisions_cc.extend(self.affaires_en_instance())
|
||||||
|
except Exception as erreur: # noqa: BLE001
|
||||||
|
resultat.erreurs.append(f"affaires en instance : {erreur}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rendues = self.decisions_rendues()
|
||||||
|
except Exception as erreur: # noqa: BLE001
|
||||||
|
resultat.erreurs.append(f"décisions rendues : {erreur}")
|
||||||
|
rendues = []
|
||||||
|
|
||||||
|
# Une décision rendue prime sur l'inscription au rôle : si une affaire
|
||||||
|
# figure dans les deux listes, c'est qu'elle vient d'être tranchée.
|
||||||
|
par_numero = {d.numero_affaire: d for d in resultat.decisions_cc}
|
||||||
|
for decision in rendues:
|
||||||
|
par_numero[decision.numero_affaire] = decision
|
||||||
|
resultat.decisions_cc = list(par_numero.values())
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
# ── Affaires en instance ─────────────────────────────────────────────────
|
||||||
|
def affaires_en_instance(self) -> list[DecisionCC]:
|
||||||
|
reponse = self.client.get(URL_AFFAIRES_DC)
|
||||||
|
if not reponse.a_reussi:
|
||||||
|
raise RuntimeError(f"HTTP {reponse.code}")
|
||||||
|
|
||||||
|
document = HTMLParser(reponse.texte)
|
||||||
|
tableau = document.css_first("table")
|
||||||
|
if tableau is None:
|
||||||
|
raise RuntimeError("tableau des affaires introuvable")
|
||||||
|
|
||||||
|
entetes = _index_des_colonnes(tableau)
|
||||||
|
affaires: list[DecisionCC] = []
|
||||||
|
for ligne in tableau.css("tbody tr"):
|
||||||
|
if affaire := _lire_ligne_instance(ligne, entetes):
|
||||||
|
affaires.append(affaire)
|
||||||
|
|
||||||
|
log.debug("affaires DC en instance", nombre=len(affaires))
|
||||||
|
return affaires
|
||||||
|
|
||||||
|
# ── Décisions rendues ────────────────────────────────────────────────────
|
||||||
|
def decisions_rendues(self) -> list[DecisionCC]:
|
||||||
|
reponse = self.client.get(URL_DECISIONS)
|
||||||
|
if not reponse.a_reussi:
|
||||||
|
raise RuntimeError(f"HTTP {reponse.code}")
|
||||||
|
|
||||||
|
document = HTMLParser(reponse.texte)
|
||||||
|
decisions: list[DecisionCC] = []
|
||||||
|
|
||||||
|
for article in document.css("article"):
|
||||||
|
texte = " ".join(article.text(separator=" ", strip=True).split())
|
||||||
|
if " DC " not in texte and " DC " not in texte:
|
||||||
|
continue
|
||||||
|
if decision := _lire_decision_rendue(texte, _lien(article)):
|
||||||
|
decisions.append(decision)
|
||||||
|
|
||||||
|
log.debug("décisions DC rendues", nombre=len(decisions))
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
|
||||||
|
def _index_des_colonnes(tableau: Node) -> dict[str, int]:
|
||||||
|
"""Associe chaque rôle de colonne à sa position, d'après les en-têtes."""
|
||||||
|
entetes = [
|
||||||
|
sans_accents(th.text(separator=" ", strip=True)).lower()
|
||||||
|
for th in tableau.css("th")
|
||||||
|
]
|
||||||
|
index: dict[str, int] = {}
|
||||||
|
for role, variantes in _COLONNES.items():
|
||||||
|
for position, entete in enumerate(entetes):
|
||||||
|
if any(entete.startswith(variante) for variante in variantes):
|
||||||
|
index[role] = position
|
||||||
|
break
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _lire_ligne_instance(ligne: Node, colonnes: dict[str, int]) -> DecisionCC | None:
|
||||||
|
cellules = [" ".join(c.text(separator=" ", strip=True).split()) for c in ligne.css("td")]
|
||||||
|
if not cellules:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cellule(role: str) -> str:
|
||||||
|
position = colonnes.get(role)
|
||||||
|
return cellules[position] if position is not None and position < len(cellules) else ""
|
||||||
|
|
||||||
|
correspondance = _AFFAIRE.search(cellule("affaire") or cellules[0])
|
||||||
|
if not correspondance:
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_decision = lire_date(cellule("date_decision"))
|
||||||
|
saisissants = cellule("saisissants")
|
||||||
|
intitule = cellule("disposition")
|
||||||
|
|
||||||
|
return DecisionCC(
|
||||||
|
numero_affaire=f"{correspondance.group(1)} DC",
|
||||||
|
date_saisine=lire_date(cellule("date_saisine")),
|
||||||
|
date_decision=date_decision,
|
||||||
|
resultat=ResultatCC.EN_INSTANCE if date_decision is None else None,
|
||||||
|
saisissants=saisissants[:300] or None,
|
||||||
|
resume=intitule[:500] or None,
|
||||||
|
url=URL_AFFAIRES_DC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lire_decision_rendue(texte: str, lien: str | None) -> DecisionCC | None:
|
||||||
|
correspondance = _AFFAIRE.search(texte)
|
||||||
|
if not correspondance:
|
||||||
|
return None
|
||||||
|
|
||||||
|
numero = correspondance.group(1)
|
||||||
|
apres = texte[correspondance.end() :]
|
||||||
|
|
||||||
|
date_decision = _date_apres_du(apres)
|
||||||
|
resultat = _resultat_du_texte(apres)
|
||||||
|
|
||||||
|
return DecisionCC(
|
||||||
|
numero_affaire=f"{numero} DC",
|
||||||
|
date_decision=date_decision,
|
||||||
|
resultat=resultat,
|
||||||
|
resume=" ".join(apres.split())[:500] or None,
|
||||||
|
url=lien,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_APRES_DU = re.compile(r"\bdu\s+(\d{1,2}(?:er)?\s+\w+\s+\d{4})", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_apres_du(fragment: str) -> date | None:
|
||||||
|
if trouve := _APRES_DU.search(fragment):
|
||||||
|
return lire_date(trouve.group(1))
|
||||||
|
return lire_date(fragment)
|
||||||
|
|
||||||
|
|
||||||
|
def _resultat_du_texte(fragment: str) -> ResultatCC | None:
|
||||||
|
plat = sans_accents(fragment).lower()
|
||||||
|
for forme, resultat in _RESULTATS:
|
||||||
|
if forme in plat:
|
||||||
|
return resultat
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lien(article: Node) -> str | None:
|
||||||
|
ancre = article.css_first("a")
|
||||||
|
if ancre is None:
|
||||||
|
return None
|
||||||
|
href = ancre.attributes.get("href") or ""
|
||||||
|
if href.startswith("http"):
|
||||||
|
return href
|
||||||
|
if href.startswith("/"):
|
||||||
|
return BASE + href
|
||||||
|
return None
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Client HTTP partagé par les collecteurs.
|
||||||
|
|
||||||
|
Trois obligations, non négociables vis-à-vis de services publics gratuits :
|
||||||
|
|
||||||
|
1. **Se présenter.** Le user-agent porte le nom du projet et une adresse de
|
||||||
|
contact, configurables dans `.env`. Un scraper anonyme est un scraper qu'on
|
||||||
|
bloque, à raison.
|
||||||
|
2. **Ne pas marteler.** Un délai minimal est respecté entre deux requêtes vers
|
||||||
|
un même hôte, et un cache disque évite de redemander ce qu'on a déjà.
|
||||||
|
3. **Ne pas insister bêtement.** Trois tentatives au maximum, avec attente
|
||||||
|
croissante, et seulement sur les erreurs qui peuvent se résoudre d'elles-mêmes
|
||||||
|
(temporisations, 5xx, 429).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pipeline import chemins
|
||||||
|
from pipeline.journal import logger
|
||||||
|
|
||||||
|
log = logger("http")
|
||||||
|
|
||||||
|
CODES_A_REESSAYER = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Reponse:
|
||||||
|
"""Réponse HTTP, éventuellement servie par le cache."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
code: int
|
||||||
|
texte: str
|
||||||
|
depuis_le_cache: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a_reussi(self) -> bool:
|
||||||
|
return 200 <= self.code < 300
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return json.loads(self.texte)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientHttp:
|
||||||
|
"""Client à cache disque et débit maîtrisé."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cache: Path | None = None,
|
||||||
|
duree_cache_h: float | None = None,
|
||||||
|
delai_minimal_s: float | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
hors_ligne: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.cache = cache or chemins.CACHE_HTTP
|
||||||
|
self.duree_cache = timedelta(
|
||||||
|
hours=duree_cache_h
|
||||||
|
if duree_cache_h is not None
|
||||||
|
else float(os.environ.get("VEILLE_CACHE_TTL_H", "6"))
|
||||||
|
)
|
||||||
|
self.delai_minimal = (
|
||||||
|
delai_minimal_s
|
||||||
|
if delai_minimal_s is not None
|
||||||
|
else float(os.environ.get("VEILLE_DELAI_REQUETES", "1.0"))
|
||||||
|
)
|
||||||
|
self.user_agent = user_agent or os.environ.get(
|
||||||
|
"VEILLE_USER_AGENT", "veille-legislative-971/1.0"
|
||||||
|
)
|
||||||
|
# Mode hors ligne : seul le cache répond. Indispensable pour que les
|
||||||
|
# tests ne dépendent jamais du réseau.
|
||||||
|
self.hors_ligne = hors_ligne
|
||||||
|
|
||||||
|
self._dernier_appel: dict[str, float] = {}
|
||||||
|
self._client = httpx.Client(
|
||||||
|
timeout=httpx.Timeout(30.0, connect=15.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": self.user_agent, "Accept-Language": "fr-FR,fr;q=0.9"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Cycle de vie ─────────────────────────────────────────────────────────
|
||||||
|
def fermer(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> ClientHttp:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_) -> None:
|
||||||
|
self.fermer()
|
||||||
|
|
||||||
|
# ── Requêtes ─────────────────────────────────────────────────────────────
|
||||||
|
def get(self, url: str, **kwargs) -> Reponse:
|
||||||
|
return self._requeter("GET", url, **kwargs)
|
||||||
|
|
||||||
|
def post(self, url: str, **kwargs) -> Reponse:
|
||||||
|
return self._requeter("POST", url, **kwargs)
|
||||||
|
|
||||||
|
def _requeter(
|
||||||
|
self,
|
||||||
|
methode: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json_corps: Any = None,
|
||||||
|
donnees: dict | None = None,
|
||||||
|
entetes: dict | None = None,
|
||||||
|
utiliser_cache: bool = True,
|
||||||
|
tentatives: int = 3,
|
||||||
|
) -> Reponse:
|
||||||
|
cle = _cle_de_cache(methode, url, json_corps, donnees)
|
||||||
|
|
||||||
|
if utiliser_cache and (en_cache := self._lire_cache(cle)):
|
||||||
|
return Reponse(url=url, code=200, texte=en_cache, depuis_le_cache=True)
|
||||||
|
|
||||||
|
if self.hors_ligne:
|
||||||
|
return Reponse(url=url, code=0, texte="", depuis_le_cache=False)
|
||||||
|
|
||||||
|
derniere_erreur: str | None = None
|
||||||
|
|
||||||
|
for tentative in range(1, tentatives + 1):
|
||||||
|
self._respecter_le_debit(url)
|
||||||
|
try:
|
||||||
|
reponse = self._client.request(
|
||||||
|
methode, url, json=json_corps, data=donnees, headers=entetes
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as erreur:
|
||||||
|
derniere_erreur = f"{type(erreur).__name__}: {erreur}"
|
||||||
|
log.debug("requête en échec", url=url, tentative=tentative, erreur=derniere_erreur)
|
||||||
|
time.sleep(min(2**tentative, 8))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if reponse.status_code in CODES_A_REESSAYER and tentative < tentatives:
|
||||||
|
attente = _attente_conseillee(reponse) or min(2**tentative, 8)
|
||||||
|
log.debug(
|
||||||
|
"réponse à réessayer",
|
||||||
|
url=url,
|
||||||
|
code=reponse.status_code,
|
||||||
|
attente_s=attente,
|
||||||
|
)
|
||||||
|
time.sleep(attente)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 200 <= reponse.status_code < 300 and utiliser_cache:
|
||||||
|
self._ecrire_cache(cle, reponse.text)
|
||||||
|
|
||||||
|
return Reponse(url=url, code=reponse.status_code, texte=reponse.text)
|
||||||
|
|
||||||
|
raise httpx.HTTPError(derniere_erreur or f"échec après {tentatives} tentatives : {url}")
|
||||||
|
|
||||||
|
# ── Débit ────────────────────────────────────────────────────────────────
|
||||||
|
def _respecter_le_debit(self, url: str) -> None:
|
||||||
|
hote = httpx.URL(url).host or ""
|
||||||
|
precedent = self._dernier_appel.get(hote)
|
||||||
|
if precedent is not None:
|
||||||
|
attente = self.delai_minimal - (time.monotonic() - precedent)
|
||||||
|
if attente > 0:
|
||||||
|
time.sleep(attente)
|
||||||
|
self._dernier_appel[hote] = time.monotonic()
|
||||||
|
|
||||||
|
# ── Cache ────────────────────────────────────────────────────────────────
|
||||||
|
def _chemin_cache(self, cle: str) -> Path:
|
||||||
|
return self.cache / cle[:2] / f"{cle}.txt"
|
||||||
|
|
||||||
|
def _lire_cache(self, cle: str) -> str | None:
|
||||||
|
fichier = self._chemin_cache(cle)
|
||||||
|
if not fichier.exists():
|
||||||
|
return None
|
||||||
|
age = datetime.now(UTC) - datetime.fromtimestamp(fichier.stat().st_mtime, UTC)
|
||||||
|
if age > self.duree_cache:
|
||||||
|
return None
|
||||||
|
return fichier.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def _ecrire_cache(self, cle: str, contenu: str) -> None:
|
||||||
|
fichier = self._chemin_cache(cle)
|
||||||
|
fichier.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fichier.write_text(contenu, encoding="utf-8")
|
||||||
|
|
||||||
|
def vider_le_cache(self) -> int:
|
||||||
|
"""Supprime les entrées périmées. Retourne le nombre de fichiers effacés."""
|
||||||
|
efface = 0
|
||||||
|
if not self.cache.exists():
|
||||||
|
return 0
|
||||||
|
limite = datetime.now(UTC) - self.duree_cache
|
||||||
|
for fichier in self.cache.rglob("*.txt"):
|
||||||
|
if datetime.fromtimestamp(fichier.stat().st_mtime, UTC) < limite:
|
||||||
|
fichier.unlink()
|
||||||
|
efface += 1
|
||||||
|
return efface
|
||||||
|
|
||||||
|
|
||||||
|
def _cle_de_cache(methode: str, url: str, json_corps: Any, donnees: dict | None) -> str:
|
||||||
|
empreinte = hashlib.sha256()
|
||||||
|
empreinte.update(methode.encode())
|
||||||
|
empreinte.update(url.encode())
|
||||||
|
if json_corps is not None:
|
||||||
|
empreinte.update(json.dumps(json_corps, sort_keys=True, ensure_ascii=False).encode())
|
||||||
|
if donnees:
|
||||||
|
empreinte.update(json.dumps(donnees, sort_keys=True, ensure_ascii=False).encode())
|
||||||
|
return empreinte.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _attente_conseillee(reponse: httpx.Response) -> float | None:
|
||||||
|
"""Respecte l'en-tête `Retry-After` quand le serveur en envoie un."""
|
||||||
|
valeur = reponse.headers.get("Retry-After")
|
||||||
|
if not valeur:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return min(float(valeur), 30.0)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Collecteur Légifrance, via l'API PISTE de la DILA.
|
||||||
|
|
||||||
|
Authentification OAuth 2.0 en `client_credentials`. Deux pièges vérifiés le
|
||||||
|
25 juillet 2026 sur le compte réel :
|
||||||
|
|
||||||
|
- une application PISTE expose **deux** couples de valeurs, et seul le couple
|
||||||
|
OAuth (« Client ID » / « Client secret ») ouvre le jeton ; la clé d'API
|
||||||
|
(« API key » / « API key secret ») produit un `invalid_client` ;
|
||||||
|
- les identifiants de production ne sont **pas** acceptés par le bac à sable,
|
||||||
|
qui exige une application distincte.
|
||||||
|
|
||||||
|
Sans identifiants, le collecteur se déclare indisponible et le pipeline bascule
|
||||||
|
sur le repli documenté : promulgations de l'Assemblée nationale, liste des lois
|
||||||
|
du Sénat, décisions du Conseil constitutionnel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
|
||||||
|
from pipeline.collecteurs.http import ClientHttp
|
||||||
|
from pipeline.journal import logger
|
||||||
|
from pipeline.modeles import Confiance, Evenement, Source, Statut, Tier, TypeEtape, TypeTexte
|
||||||
|
from pipeline.parseurs.dates_fr import lire_date
|
||||||
|
|
||||||
|
log = logger("collecteur.legifrance")
|
||||||
|
|
||||||
|
URL_JETON = {
|
||||||
|
"prod": "https://oauth.piste.gouv.fr/api/oauth/token",
|
||||||
|
"sandbox": "https://sandbox-oauth.piste.gouv.fr/api/oauth/token",
|
||||||
|
}
|
||||||
|
URL_API = {
|
||||||
|
"prod": "https://api.piste.gouv.fr/dila/legifrance/lf-engine-app",
|
||||||
|
"sandbox": "https://sandbox-api.piste.gouv.fr/dila/legifrance/lf-engine-app",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Le lien public d'un texte se déduit de son identifiant JORF.
|
||||||
|
GABARIT_PUBLIC = "https://www.legifrance.gouv.fr/jorf/id/{identifiant}"
|
||||||
|
|
||||||
|
|
||||||
|
class CollecteurLegifrance(Collecteur):
|
||||||
|
"""Interroge le fonds LODA (lois, ordonnances, décrets, arrêtés)."""
|
||||||
|
|
||||||
|
nom = "legifrance"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: ClientHttp,
|
||||||
|
*,
|
||||||
|
numeros_a_verifier: list[str] | None = None,
|
||||||
|
annee: int = 2026,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.numeros_a_verifier = numeros_a_verifier or []
|
||||||
|
self.annee = annee
|
||||||
|
self._jeton: str | None = None
|
||||||
|
self._jeton_expire_a: float = 0.0
|
||||||
|
|
||||||
|
# ── Disponibilité ────────────────────────────────────────────────────────
|
||||||
|
def est_disponible(self) -> tuple[bool, str | None]:
|
||||||
|
if not os.environ.get("LEGIFRANCE_CLIENT_ID"):
|
||||||
|
return False, (
|
||||||
|
"LEGIFRANCE_CLIENT_ID absent de .env — repli sur l'Assemblée "
|
||||||
|
"nationale, le Sénat et le Conseil constitutionnel"
|
||||||
|
)
|
||||||
|
if not os.environ.get("LEGIFRANCE_CLIENT_SECRET"):
|
||||||
|
return False, "LEGIFRANCE_CLIENT_SECRET absent de .env"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# ── Authentification ─────────────────────────────────────────────────────
|
||||||
|
@property
|
||||||
|
def environnement(self) -> str:
|
||||||
|
valeur = os.environ.get("LEGIFRANCE_ENV", "prod").lower()
|
||||||
|
return valeur if valeur in URL_JETON else "prod"
|
||||||
|
|
||||||
|
def _obtenir_jeton(self) -> str:
|
||||||
|
"""Jeton OAuth, renouvelé une minute avant son expiration."""
|
||||||
|
if self._jeton and time.monotonic() < self._jeton_expire_a:
|
||||||
|
return self._jeton
|
||||||
|
|
||||||
|
reponse = self.client.post(
|
||||||
|
URL_JETON[self.environnement],
|
||||||
|
donnees={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": os.environ["LEGIFRANCE_CLIENT_ID"],
|
||||||
|
"client_secret": os.environ["LEGIFRANCE_CLIENT_SECRET"],
|
||||||
|
"scope": "openid",
|
||||||
|
},
|
||||||
|
utiliser_cache=False,
|
||||||
|
)
|
||||||
|
if not reponse.a_reussi:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"authentification PISTE refusée (HTTP {reponse.code}) : "
|
||||||
|
f"{reponse.texte[:200]} — vérifier qu'il s'agit bien du couple "
|
||||||
|
"OAuth et non de la clé d'API"
|
||||||
|
)
|
||||||
|
|
||||||
|
charge = reponse.json()
|
||||||
|
self._jeton = charge["access_token"]
|
||||||
|
self._jeton_expire_a = time.monotonic() + max(int(charge.get("expires_in", 3600)) - 60, 60)
|
||||||
|
log.debug("jeton PISTE obtenu", scope=charge.get("scope"))
|
||||||
|
return self._jeton
|
||||||
|
|
||||||
|
def _entetes(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._obtenir_jeton()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Collecte ─────────────────────────────────────────────────────────────
|
||||||
|
def _collecter(self) -> ResultatCollecte:
|
||||||
|
resultat = ResultatCollecte(collecteur=self.nom)
|
||||||
|
|
||||||
|
for numero in self.numeros_a_verifier:
|
||||||
|
try:
|
||||||
|
if texte := self.chercher_par_numero(numero):
|
||||||
|
resultat.textes.append(texte)
|
||||||
|
except Exception as erreur: # noqa: BLE001
|
||||||
|
resultat.erreurs.append(f"{numero} : {erreur}")
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
def chercher_par_numero(self, numero: str) -> TexteCollecte | None:
|
||||||
|
"""Retrouve une loi par son numéro officiel (« 2026-491 »)."""
|
||||||
|
charge = {
|
||||||
|
"recherche": {
|
||||||
|
"champs": [
|
||||||
|
{
|
||||||
|
"typeChamp": "NUM",
|
||||||
|
"criteres": [
|
||||||
|
{"typeRecherche": "EXACTE", "valeur": numero, "operateur": "ET"}
|
||||||
|
],
|
||||||
|
"operateur": "ET",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filtres": [],
|
||||||
|
"pageNumber": 1,
|
||||||
|
"pageSize": 5,
|
||||||
|
"operateur": "ET",
|
||||||
|
"sort": "PERTINENCE",
|
||||||
|
"typePagination": "DEFAUT",
|
||||||
|
},
|
||||||
|
"fond": "LODA_DATE",
|
||||||
|
}
|
||||||
|
|
||||||
|
reponse = self.client.post(
|
||||||
|
f"{URL_API[self.environnement]}/search",
|
||||||
|
json_corps=charge,
|
||||||
|
entetes=self._entetes(),
|
||||||
|
)
|
||||||
|
if not reponse.a_reussi:
|
||||||
|
raise RuntimeError(f"recherche Légifrance HTTP {reponse.code}: {reponse.texte[:160]}")
|
||||||
|
|
||||||
|
donnees = reponse.json()
|
||||||
|
for element in donnees.get("results") or []:
|
||||||
|
for titre in element.get("titles") or []:
|
||||||
|
if texte := _lire_titre(titre, numero):
|
||||||
|
return texte
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lire_titre(titre: dict, numero_attendu: str) -> TexteCollecte | None:
|
||||||
|
"""Convertit une entrée de résultat Légifrance en texte collecté."""
|
||||||
|
intitule = (titre.get("title") or "").strip()
|
||||||
|
if not intitule or numero_attendu not in intitule:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifiant = titre.get("cid") or titre.get("id") or ""
|
||||||
|
url = GABARIT_PUBLIC.format(identifiant=identifiant) if identifiant else None
|
||||||
|
if url is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
signature = _date_du_titre(titre, intitule)
|
||||||
|
organique = "LOI organique" in intitule or "LOI ORGANIQUE" in intitule.upper()
|
||||||
|
|
||||||
|
evenements = []
|
||||||
|
if signature:
|
||||||
|
evenements.append(
|
||||||
|
Evenement(
|
||||||
|
date_evenement=signature,
|
||||||
|
type_etape=TypeEtape.PROMULGATION,
|
||||||
|
description=f"Promulgation constatée sur Légifrance : {intitule[:200]}",
|
||||||
|
source_url=url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TexteCollecte(
|
||||||
|
titre=intitule,
|
||||||
|
source_url=url,
|
||||||
|
collecteur="legifrance",
|
||||||
|
numero_officiel=numero_attendu,
|
||||||
|
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=signature,
|
||||||
|
identifiant_externe=identifiant,
|
||||||
|
evenements=evenements,
|
||||||
|
sources=[
|
||||||
|
Source(
|
||||||
|
url=url,
|
||||||
|
titre=intitule[:280],
|
||||||
|
editeur="Légifrance",
|
||||||
|
date_publication=signature,
|
||||||
|
tier=Tier.T1,
|
||||||
|
confiance=Confiance.HIGH,
|
||||||
|
marqueur=f"legifrance-{identifiant}",
|
||||||
|
fichier_origine="collecteur:legifrance",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_du_titre(titre: dict, intitule: str) -> date | None:
|
||||||
|
"""Date de signature : champ dédié quand il existe, sinon lue dans l'intitulé.
|
||||||
|
|
||||||
|
Les résultats de recherche laissent souvent `dateSignature` à `None` ;
|
||||||
|
l'intitulé officiel, lui, porte toujours la date (« LOI n° 2026-491 du
|
||||||
|
12 juin 2026 … »).
|
||||||
|
"""
|
||||||
|
for champ in ("dateSignature", "dateDebut", "datePublication"):
|
||||||
|
if valeur := titre.get(champ):
|
||||||
|
if lue := lire_date(str(valeur)):
|
||||||
|
return lue
|
||||||
|
return lire_date(intitule)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Collecteur du Sénat — liste chronologique des lois promulguées.
|
||||||
|
|
||||||
|
C'est le repli principal lorsque l'API Légifrance est indisponible, et un
|
||||||
|
contrôle croisé lorsqu'elle fonctionne. La page rend, pour chaque loi
|
||||||
|
promulguée de la session, son numéro, sa date, son intitulé complet et le lien
|
||||||
|
vers son dossier législatif.
|
||||||
|
|
||||||
|
L'adresse indiquée au §5.1 du cahier des charges — `senat.fr/lois/index.html` —
|
||||||
|
renvoie une erreur 404 depuis la refonte du site. L'équivalent fonctionnel est
|
||||||
|
`senat.fr/dossiers-legislatifs/lois-promulguees.html`, vérifié le 25 juillet
|
||||||
|
2026 : 51 lois y figuraient, dont les 27 de la fenêtre de veille.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
|
||||||
|
from pipeline.collecteurs.http import ClientHttp
|
||||||
|
from pipeline.journal import logger
|
||||||
|
from pipeline.modeles import Confiance, Evenement, Source, Statut, Tier, TypeEtape, TypeTexte
|
||||||
|
from pipeline.parseurs.dates_fr import lire_date
|
||||||
|
|
||||||
|
log = logger("collecteur.senat")
|
||||||
|
|
||||||
|
BASE = "https://www.senat.fr"
|
||||||
|
URL_LOIS_PROMULGUEES = f"{BASE}/dossiers-legislatifs/lois-promulguees.html"
|
||||||
|
|
||||||
|
# « Loi n° 2026-650 du 23 juillet 2026 relative au renforcement… »
|
||||||
|
# « Loi organique n° 2026-410 du 28 mai 2026 … »
|
||||||
|
_INTITULE = re.compile(
|
||||||
|
r"Loi\s+(organique\s+)?n°\s*(\d{4}-\d{1,4})\s+du\s+"
|
||||||
|
r"(\d{1,2}(?:er)?\s+\w+\.?\s+\d{4})\s+(.*)",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollecteurSenat(Collecteur):
|
||||||
|
nom = "senat"
|
||||||
|
|
||||||
|
def __init__(self, client: ClientHttp, *, annees: tuple[int, ...] = (2026,)) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.annees = annees
|
||||||
|
|
||||||
|
def _collecter(self) -> ResultatCollecte:
|
||||||
|
resultat = ResultatCollecte(collecteur=self.nom)
|
||||||
|
|
||||||
|
reponse = self.client.get(URL_LOIS_PROMULGUEES)
|
||||||
|
if not reponse.a_reussi:
|
||||||
|
raise RuntimeError(f"HTTP {reponse.code} sur {URL_LOIS_PROMULGUEES}")
|
||||||
|
|
||||||
|
document = HTMLParser(reponse.texte)
|
||||||
|
vus: set[str] = set()
|
||||||
|
|
||||||
|
for ancre in document.css("a"):
|
||||||
|
libelle = " ".join(ancre.text(separator=" ", strip=True).split())
|
||||||
|
texte = self._lire_intitule(libelle, ancre.attributes.get("href"))
|
||||||
|
if texte is None or texte.numero_officiel in vus:
|
||||||
|
continue
|
||||||
|
vus.add(texte.numero_officiel)
|
||||||
|
resultat.textes.append(texte)
|
||||||
|
|
||||||
|
log.debug("lois promulguées relevées", nombre=len(resultat.textes))
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
def _lire_intitule(self, libelle: str, href: str | None) -> TexteCollecte | None:
|
||||||
|
correspondance = _INTITULE.search(libelle)
|
||||||
|
if not correspondance:
|
||||||
|
return None
|
||||||
|
|
||||||
|
organique = bool(correspondance.group(1))
|
||||||
|
numero = correspondance.group(2)
|
||||||
|
if int(numero.split("-")[0]) not in self.annees:
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_promulgation = lire_date(correspondance.group(3))
|
||||||
|
if date_promulgation is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Le libellé se termine par un état (« parue », « en cours »)
|
||||||
|
# qu'il ne faut pas confondre avec l'objet de la loi.
|
||||||
|
objet = re.split(r"\s+(?:parue|en cours)\b", correspondance.group(4))[0].strip(" .;")
|
||||||
|
titre = (
|
||||||
|
f"LOI {'organique ' if organique else ''}n° {numero} "
|
||||||
|
f"du {correspondance.group(3)} {objet}"
|
||||||
|
)
|
||||||
|
|
||||||
|
url = _absolu(href) or URL_LOIS_PROMULGUEES
|
||||||
|
|
||||||
|
return TexteCollecte(
|
||||||
|
titre=" ".join(titre.split()),
|
||||||
|
source_url=url,
|
||||||
|
collecteur=self.nom,
|
||||||
|
numero_officiel=numero,
|
||||||
|
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date_promulgation,
|
||||||
|
evenements=[
|
||||||
|
Evenement(
|
||||||
|
date_evenement=date_promulgation,
|
||||||
|
type_etape=TypeEtape.PROMULGATION,
|
||||||
|
description=f"Loi promulguée n° {numero}, relevée sur la liste du Sénat",
|
||||||
|
source_url=url,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
sources=[
|
||||||
|
Source(
|
||||||
|
url=url,
|
||||||
|
titre=" ".join(titre.split())[:280],
|
||||||
|
editeur="Sénat",
|
||||||
|
date_publication=date_promulgation,
|
||||||
|
tier=Tier.T1,
|
||||||
|
confiance=Confiance.HIGH,
|
||||||
|
marqueur=f"senat-{numero}",
|
||||||
|
fichier_origine="collecteur:senat",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _absolu(href: str | None) -> str | None:
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
if href.startswith("http"):
|
||||||
|
return href
|
||||||
|
if href.startswith("/"):
|
||||||
|
return BASE + href
|
||||||
|
return None
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Rapprochement d'un texte collecté avec les textes déjà en base.
|
||||||
|
|
||||||
|
Deux voies, dans cet ordre :
|
||||||
|
|
||||||
|
1. **Le numéro officiel.** Deux textes portant le même numéro de loi sont le
|
||||||
|
même texte, sans discussion. C'est le cas facile, et le plus fréquent une
|
||||||
|
fois la promulgation intervenue.
|
||||||
|
2. **La similarité de titre**, pour les textes non encore promulgués, qui n'ont
|
||||||
|
pas de numéro. Le rapport les désigne par un nom d'usage (« loi Riposte »)
|
||||||
|
là où les sources officielles emploient l'intitulé complet (« Loi visant à
|
||||||
|
offrir des réponses immédiates aux phénomènes troublant l'ordre public… »).
|
||||||
|
La comparaison brute échouerait ; on compare donc des titres normalisés, et
|
||||||
|
on tient compte des mots significatifs communs.
|
||||||
|
|
||||||
|
Trois issues possibles, et c'est important : rapproché, **à signaler** (au-delà
|
||||||
|
du seuil bas mais en-deçà du seuil haut — un humain tranche), ou nouveau. Le
|
||||||
|
pipeline ne fusionne jamais sur une correspondance douteuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pipeline import chemins
|
||||||
|
from pipeline.parseurs.dates_fr import sans_accents
|
||||||
|
|
||||||
|
# Mots vides et vocabulaire juridique passe-partout : deux lois quelconques les
|
||||||
|
# partagent, ils ne prouvent donc aucune parenté.
|
||||||
|
_MOTS_IGNORES = frozenset(
|
||||||
|
"""loi organique projet proposition relative relatif visant portant diverses
|
||||||
|
dispositions de des du la le les l a au aux et en pour par sur dans un une
|
||||||
|
ainsi que qui plus mesures texte article n no ndeg""".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
_NON_ALPHANUM = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
class Issue(StrEnum):
|
||||||
|
RAPPROCHE = "rapproche"
|
||||||
|
A_SIGNALER = "a_signaler"
|
||||||
|
NOUVEAU = "nouveau"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Correspondance:
|
||||||
|
"""Résultat d'un rapprochement."""
|
||||||
|
|
||||||
|
issue: Issue
|
||||||
|
texte_id: str | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
motif: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def est_certaine(self) -> bool:
|
||||||
|
return self.issue is Issue.RAPPROCHE
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _seuils() -> tuple[float, float]:
|
||||||
|
configuration = yaml.safe_load(chemins.CONFIG.read_text(encoding="utf-8"))
|
||||||
|
rapprochement = configuration.get("rapprochement", {})
|
||||||
|
return (
|
||||||
|
float(rapprochement.get("seuil_similarite", 0.86)),
|
||||||
|
float(rapprochement.get("seuil_signalement", 0.72)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normaliser(titre: str) -> str:
|
||||||
|
"""Réduit un titre à ses mots significatifs, sans accent ni ponctuation."""
|
||||||
|
plat = sans_accents(titre).lower()
|
||||||
|
mots = [m for m in _NON_ALPHANUM.split(plat) if m and m not in _MOTS_IGNORES]
|
||||||
|
return " ".join(mots)
|
||||||
|
|
||||||
|
|
||||||
|
def similarite(gauche: str, droite: str) -> float:
|
||||||
|
"""Similarité de deux titres, entre 0 et 1.
|
||||||
|
|
||||||
|
Combine deux mesures : le recouvrement des mots significatifs, qui résiste
|
||||||
|
aux réordonnancements, et la similarité de séquence, qui capte les
|
||||||
|
variantes orthographiques. La plus favorable des deux l'emporte — un titre
|
||||||
|
court mais entièrement contenu dans un titre long doit être reconnu.
|
||||||
|
"""
|
||||||
|
a, b = normaliser(gauche), normaliser(droite)
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
mots_a, mots_b = set(a.split()), set(b.split())
|
||||||
|
communs = mots_a & mots_b
|
||||||
|
recouvrement = len(communs) / min(len(mots_a), len(mots_b))
|
||||||
|
sequence = SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
return max(recouvrement, sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def rapprocher(
|
||||||
|
*,
|
||||||
|
titre: str,
|
||||||
|
numero_officiel: str | None,
|
||||||
|
candidats: list[tuple[str, str, str | None]],
|
||||||
|
) -> Correspondance:
|
||||||
|
"""Rapproche un texte collecté des textes en base.
|
||||||
|
|
||||||
|
`candidats` est une liste de `(identifiant, titre, numero_officiel)`.
|
||||||
|
"""
|
||||||
|
seuil_haut, seuil_bas = _seuils()
|
||||||
|
|
||||||
|
if numero_officiel:
|
||||||
|
for identifiant, _, numero in candidats:
|
||||||
|
if numero and numero == numero_officiel:
|
||||||
|
return Correspondance(
|
||||||
|
issue=Issue.RAPPROCHE,
|
||||||
|
texte_id=identifiant,
|
||||||
|
score=1.0,
|
||||||
|
motif=f"numéro officiel identique ({numero_officiel})",
|
||||||
|
)
|
||||||
|
|
||||||
|
meilleur: tuple[float, str] | None = None
|
||||||
|
for identifiant, titre_candidat, _ in candidats:
|
||||||
|
score = similarite(titre, titre_candidat)
|
||||||
|
if meilleur is None or score > meilleur[0]:
|
||||||
|
meilleur = (score, identifiant)
|
||||||
|
|
||||||
|
if meilleur is None:
|
||||||
|
return Correspondance(issue=Issue.NOUVEAU, motif="aucun candidat en base")
|
||||||
|
|
||||||
|
score, identifiant = meilleur
|
||||||
|
if score >= seuil_haut:
|
||||||
|
return Correspondance(
|
||||||
|
issue=Issue.RAPPROCHE,
|
||||||
|
texte_id=identifiant,
|
||||||
|
score=score,
|
||||||
|
motif=f"similarité de titre {score:.2f} ≥ {seuil_haut}",
|
||||||
|
)
|
||||||
|
if score >= seuil_bas:
|
||||||
|
return Correspondance(
|
||||||
|
issue=Issue.A_SIGNALER,
|
||||||
|
texte_id=identifiant,
|
||||||
|
score=score,
|
||||||
|
motif=(
|
||||||
|
f"similarité de titre {score:.2f}, entre le seuil de signalement "
|
||||||
|
f"({seuil_bas}) et celui de rapprochement ({seuil_haut}) — "
|
||||||
|
"vérification humaine requise"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Correspondance(
|
||||||
|
issue=Issue.NOUVEAU, score=score, motif=f"meilleure similarité {score:.2f}, sous les seuils"
|
||||||
|
)
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""Run de veille : collecte, rapprochement, mise à jour, rapport.
|
||||||
|
|
||||||
|
Enchaînement, conforme au §5 du cahier des charges :
|
||||||
|
|
||||||
|
1. **Collecte** — chaque collecteur isolé, tolérant à l'échec, avec cache HTTP
|
||||||
|
et user-agent honnête. Un collecteur en panne n'arrête pas le run.
|
||||||
|
2. **Rapprochement** — par numéro officiel puis par similarité de titre, avec
|
||||||
|
une zone de signalement où le pipeline s'abstient plutôt que de fusionner.
|
||||||
|
3. **Classification** — thèmes et pertinence Guadeloupe pour les textes
|
||||||
|
nouveaux uniquement ; les entrées issues du corpus gardent leurs valeurs.
|
||||||
|
Toute classification automatique est marquée `confiance = 'low'` et
|
||||||
|
« à vérifier ».
|
||||||
|
4. **Rapport de run** — écrit dans `veille_log` et `veille_changements`.
|
||||||
|
5. **Sortie statique** — `data/textes.json` régénéré pour le mode dégradé.
|
||||||
|
|
||||||
|
`--dry-run` effectue la collecte réelle et calcule tous les écarts, sans écrire
|
||||||
|
une seule ligne en base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pipeline import chemins, db, guadeloupe
|
||||||
|
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
|
||||||
|
from pipeline.collecteurs.conseil_constitutionnel import CollecteurConseilConstitutionnel
|
||||||
|
from pipeline.collecteurs.http import ClientHttp
|
||||||
|
from pipeline.collecteurs.legifrance import CollecteurLegifrance
|
||||||
|
from pipeline.collecteurs.senat import CollecteurSenat
|
||||||
|
from pipeline.journal import configurer, logger
|
||||||
|
from pipeline.modeles import Confiance, DecisionCC, Statut, Texte
|
||||||
|
from pipeline.parseurs.tableaux_sec10 import _deduire_themes
|
||||||
|
from pipeline.rapprochement import Issue, rapprocher
|
||||||
|
|
||||||
|
log = logger("update")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ecart:
|
||||||
|
"""Une différence constatée entre la base et les sources officielles."""
|
||||||
|
|
||||||
|
texte_id: str | None
|
||||||
|
nature: str # ajout | statut | date | source | decision_cc | signalement
|
||||||
|
champ: str | None = None
|
||||||
|
ancienne_valeur: str | None = None
|
||||||
|
nouvelle_valeur: str | None = None
|
||||||
|
description: str = ""
|
||||||
|
collecteur: str = ""
|
||||||
|
|
||||||
|
def en_ligne(self) -> str:
|
||||||
|
cible = self.texte_id or "(nouveau)"
|
||||||
|
if self.ancienne_valeur or self.nouvelle_valeur:
|
||||||
|
return (
|
||||||
|
f" [{self.nature:12}] {cible:32} {self.champ or ''} : "
|
||||||
|
f"{self.ancienne_valeur or '—'} → {self.nouvelle_valeur or '—'}"
|
||||||
|
)
|
||||||
|
return f" [{self.nature:12}] {cible:32} {self.description}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RapportRun:
|
||||||
|
"""Ce qu'un run a vu et fait."""
|
||||||
|
|
||||||
|
mode: str
|
||||||
|
ecarts: list[Ecart] = field(default_factory=list)
|
||||||
|
collectes: list[ResultatCollecte] = field(default_factory=list)
|
||||||
|
alertes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ajouts(self) -> int:
|
||||||
|
return sum(1 for e in self.ecarts if e.nature == "ajout")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modifications(self) -> int:
|
||||||
|
return sum(1 for e in self.ecarts if e.nature not in ("ajout", "signalement"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signalements(self) -> int:
|
||||||
|
return sum(1 for e in self.ecarts if e.nature == "signalement")
|
||||||
|
|
||||||
|
def en_texte(self) -> str:
|
||||||
|
lignes = [
|
||||||
|
f"Rapport de veille — mode « {self.mode} »",
|
||||||
|
"=" * 44,
|
||||||
|
"",
|
||||||
|
"Collecteurs",
|
||||||
|
]
|
||||||
|
for collecte in self.collectes:
|
||||||
|
if collecte.ignore:
|
||||||
|
etat = f"ignoré — {collecte.motif_ignore}"
|
||||||
|
elif collecte.erreurs:
|
||||||
|
etat = f"échec — {' ; '.join(collecte.erreurs)[:200]}"
|
||||||
|
else:
|
||||||
|
etat = (
|
||||||
|
f"{len(collecte.textes)} texte(s), "
|
||||||
|
f"{len(collecte.decisions_cc)} décision(s)"
|
||||||
|
)
|
||||||
|
lignes.append(f" {collecte.collecteur:26} {etat} ({collecte.duree_s:.1f} s)")
|
||||||
|
|
||||||
|
lignes += [
|
||||||
|
"",
|
||||||
|
f"Écarts détectés : {len(self.ecarts)}",
|
||||||
|
f" ajouts : {self.ajouts}",
|
||||||
|
f" modifications : {self.modifications}",
|
||||||
|
f" signalements : {self.signalements}",
|
||||||
|
]
|
||||||
|
if self.ecarts:
|
||||||
|
lignes.append("")
|
||||||
|
lignes.extend(e.en_ligne() for e in self.ecarts)
|
||||||
|
if self.alertes:
|
||||||
|
lignes += ["", "Alertes"] + [f" ! {a}" for a in self.alertes]
|
||||||
|
if not self.ecarts:
|
||||||
|
lignes += ["", " La base est conforme aux sources officielles consultées."]
|
||||||
|
return "\n".join(lignes)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Collecte
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def collecter(cx: sqlite3.Connection, client: ClientHttp) -> list[ResultatCollecte]:
|
||||||
|
"""Lance tous les collecteurs, chacun isolé de ses voisins."""
|
||||||
|
numeros = [
|
||||||
|
ligne["numero_officiel"]
|
||||||
|
for ligne in cx.execute(
|
||||||
|
"SELECT numero_officiel FROM textes WHERE numero_officiel IS NOT NULL "
|
||||||
|
"ORDER BY numero_officiel"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
collecteurs = [
|
||||||
|
CollecteurLegifrance(client, numeros_a_verifier=numeros),
|
||||||
|
CollecteurSenat(client),
|
||||||
|
CollecteurConseilConstitutionnel(client),
|
||||||
|
]
|
||||||
|
return [collecteur.collecter() for collecteur in collecteurs]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Comparaison
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def comparer(cx: sqlite3.Connection, collectes: list[ResultatCollecte]) -> RapportRun:
|
||||||
|
"""Calcule les écarts entre la base et ce que les sources rapportent."""
|
||||||
|
rapport = RapportRun(mode="comparaison", collectes=collectes)
|
||||||
|
|
||||||
|
candidats = [
|
||||||
|
(ligne["id"], ligne["titre_court"], ligne["numero_officiel"])
|
||||||
|
for ligne in cx.execute("SELECT id, titre_court, numero_officiel FROM textes")
|
||||||
|
]
|
||||||
|
connus = {
|
||||||
|
ligne["id"]: ligne
|
||||||
|
for ligne in cx.execute("SELECT * FROM textes")
|
||||||
|
}
|
||||||
|
affaires_connues = {
|
||||||
|
ligne["numero_affaire"]: ligne
|
||||||
|
for ligne in cx.execute("SELECT * FROM decisions_cc")
|
||||||
|
}
|
||||||
|
|
||||||
|
for collecte in collectes:
|
||||||
|
for texte in collecte.textes:
|
||||||
|
_comparer_texte(texte, candidats, connus, rapport, collecte.collecteur)
|
||||||
|
for decision in collecte.decisions_cc:
|
||||||
|
_comparer_decision(decision, affaires_connues, rapport, collecte.collecteur)
|
||||||
|
|
||||||
|
return rapport
|
||||||
|
|
||||||
|
|
||||||
|
def _comparer_texte(
|
||||||
|
collecte: TexteCollecte,
|
||||||
|
candidats: list[tuple[str, str, str | None]],
|
||||||
|
connus: dict[str, sqlite3.Row],
|
||||||
|
rapport: RapportRun,
|
||||||
|
collecteur: str,
|
||||||
|
) -> None:
|
||||||
|
correspondance = rapprocher(
|
||||||
|
titre=collecte.titre, numero_officiel=collecte.numero_officiel, candidats=candidats
|
||||||
|
)
|
||||||
|
|
||||||
|
if correspondance.issue is Issue.NOUVEAU:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=None,
|
||||||
|
nature="ajout",
|
||||||
|
description=f"{collecte.numero_officiel or '—'} · {collecte.titre[:110]}",
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if correspondance.issue is Issue.A_SIGNALER:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=correspondance.texte_id,
|
||||||
|
nature="signalement",
|
||||||
|
description=f"{correspondance.motif} — « {collecte.titre[:80]} »",
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
existant = connus.get(correspondance.texte_id or "")
|
||||||
|
if existant is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Changement de statut : c'est l'événement que la veille existe pour voir.
|
||||||
|
if collecte.statut and existant["statut"] != str(collecte.statut):
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=existant["id"],
|
||||||
|
nature="statut",
|
||||||
|
champ="statut",
|
||||||
|
ancienne_valeur=existant["statut"],
|
||||||
|
nouvelle_valeur=str(collecte.statut),
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for champ, valeur in (
|
||||||
|
("numero_officiel", collecte.numero_officiel),
|
||||||
|
("date_promulgation", collecte.date_promulgation),
|
||||||
|
("date_adoption", collecte.date_adoption),
|
||||||
|
):
|
||||||
|
if valeur is None:
|
||||||
|
continue
|
||||||
|
attendu = valeur.isoformat() if isinstance(valeur, date) else str(valeur)
|
||||||
|
if existant[champ] != attendu:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=existant["id"],
|
||||||
|
nature="date" if champ.startswith("date") else "autre",
|
||||||
|
champ=champ,
|
||||||
|
ancienne_valeur=existant[champ],
|
||||||
|
nouvelle_valeur=attendu,
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparer_decision(
|
||||||
|
decision: DecisionCC,
|
||||||
|
connues: dict[str, sqlite3.Row],
|
||||||
|
rapport: RapportRun,
|
||||||
|
collecteur: str,
|
||||||
|
) -> None:
|
||||||
|
existante = connues.get(decision.numero_affaire)
|
||||||
|
|
||||||
|
if existante is None:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=None,
|
||||||
|
nature="decision_cc",
|
||||||
|
description=(
|
||||||
|
f"affaire inconnue en base : {decision.numero_affaire} — "
|
||||||
|
f"{(decision.resume or '')[:90]}"
|
||||||
|
),
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if decision.date_decision and not existante["date_decision"]:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=existante["texte_id"],
|
||||||
|
nature="decision_cc",
|
||||||
|
champ=f"{decision.numero_affaire} · date_decision",
|
||||||
|
ancienne_valeur=None,
|
||||||
|
nouvelle_valeur=decision.date_decision.isoformat(),
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision.date_saisine and not existante["date_saisine"]:
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=existante["texte_id"],
|
||||||
|
nature="decision_cc",
|
||||||
|
champ=f"{decision.numero_affaire} · date_saisine",
|
||||||
|
ancienne_valeur=None,
|
||||||
|
nouvelle_valeur=decision.date_saisine.isoformat(),
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
decision.resultat
|
||||||
|
and str(decision.resultat) != "en_instance"
|
||||||
|
and existante["resultat"] != str(decision.resultat)
|
||||||
|
):
|
||||||
|
rapport.ecarts.append(
|
||||||
|
Ecart(
|
||||||
|
texte_id=existante["texte_id"],
|
||||||
|
nature="decision_cc",
|
||||||
|
champ=f"{decision.numero_affaire} · resultat",
|
||||||
|
ancienne_valeur=existante["resultat"],
|
||||||
|
nouvelle_valeur=str(decision.resultat),
|
||||||
|
collecteur=collecteur,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Application
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> None:
|
||||||
|
"""Écrit les écarts en base. Jamais appelé en mode `--dry-run`."""
|
||||||
|
with db.transaction(cx):
|
||||||
|
for ecart in rapport.ecarts:
|
||||||
|
db.enregistrer_changement(
|
||||||
|
cx,
|
||||||
|
run_id,
|
||||||
|
texte_id=ecart.texte_id,
|
||||||
|
nature=ecart.nature if ecart.nature in _NATURES_SQL else "autre",
|
||||||
|
champ=ecart.champ,
|
||||||
|
ancienne_valeur=ecart.ancienne_valeur,
|
||||||
|
nouvelle_valeur=ecart.nouvelle_valeur,
|
||||||
|
description=ecart.description or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ecart.texte_id and ecart.champ and ecart.nature in ("statut", "date"):
|
||||||
|
cx.execute(
|
||||||
|
f"UPDATE textes SET {ecart.champ} = ?, maj_le = datetime('now'), " # noqa: S608
|
||||||
|
"derniere_verif = datetime('now') WHERE id = ?",
|
||||||
|
(ecart.nouvelle_valeur, ecart.texte_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
cx.execute(
|
||||||
|
"UPDATE textes SET derniere_verif = datetime('now') "
|
||||||
|
"WHERE numero_officiel IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_NATURES_SQL = {"ajout", "statut", "date", "source", "autre"}
|
||||||
|
|
||||||
|
|
||||||
|
def classer_nouveau(collecte: TexteCollecte) -> Texte:
|
||||||
|
"""Construit un texte à partir d'une collecte, en signalant l'automatisme.
|
||||||
|
|
||||||
|
Conformément au §5.3, toute classification automatique porte la confiance
|
||||||
|
la plus basse et le drapeau de revue : la machine propose, l'humain valide.
|
||||||
|
"""
|
||||||
|
cotation = guadeloupe.coter(collecte.titre, collecte.resume)
|
||||||
|
|
||||||
|
return Texte(
|
||||||
|
id=_identifiant(collecte),
|
||||||
|
numero_officiel=collecte.numero_officiel,
|
||||||
|
type=collecte.type or "loi",
|
||||||
|
titre_court=collecte.titre[:300],
|
||||||
|
titre_officiel=collecte.titre,
|
||||||
|
statut=collecte.statut or Statut.PROMULGUEE,
|
||||||
|
date_promulgation=collecte.date_promulgation,
|
||||||
|
date_adoption=collecte.date_adoption,
|
||||||
|
themes=_deduire_themes(collecte.titre),
|
||||||
|
guadeloupe_pertinence=cotation.pertinence,
|
||||||
|
guadeloupe_note=cotation.note,
|
||||||
|
resume=collecte.resume,
|
||||||
|
confiance=Confiance.LOW,
|
||||||
|
source_seed=f"collecteur:{collecte.collecteur}",
|
||||||
|
a_verifier=True,
|
||||||
|
motif_verification=(
|
||||||
|
"Texte ajouté automatiquement par le pipeline : thèmes, impacts et "
|
||||||
|
"pertinence Guadeloupe sont déduits et n'ont pas été relus."
|
||||||
|
),
|
||||||
|
sources=collecte.sources,
|
||||||
|
evenements=collecte.evenements,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identifiant(collecte: TexteCollecte) -> str:
|
||||||
|
if collecte.numero_officiel:
|
||||||
|
return f"loi-{collecte.numero_officiel}"
|
||||||
|
from pipeline.rapprochement import normaliser
|
||||||
|
|
||||||
|
mots = normaliser(collecte.titre).split()[:6]
|
||||||
|
return "-".join(mots) or "texte-sans-titre"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Sortie statique
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def exporter_json(cx: sqlite3.Connection) -> int:
|
||||||
|
"""Régénère `data/textes.json` — dump complet pour le mode dégradé."""
|
||||||
|
textes = []
|
||||||
|
for ligne in cx.execute("SELECT * FROM v_textes ORDER BY id"):
|
||||||
|
entree = dict(ligne)
|
||||||
|
for champ in ("themes", "impacts", "points_cles"):
|
||||||
|
entree[champ] = json.loads(entree[champ]) if entree[champ] else None
|
||||||
|
entree["sources"] = [
|
||||||
|
dict(s)
|
||||||
|
for s in cx.execute(
|
||||||
|
"SELECT url, titre, editeur, date_publication, tier, extrait_verbatim, confiance "
|
||||||
|
"FROM sources WHERE texte_id = ? ORDER BY tier, id",
|
||||||
|
(ligne["id"],),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
entree["evenements"] = [
|
||||||
|
dict(e)
|
||||||
|
for e in cx.execute(
|
||||||
|
"SELECT date_evenement, type_etape, description, source_url, previsionnel "
|
||||||
|
"FROM evenements WHERE texte_id = ? ORDER BY date_evenement",
|
||||||
|
(ligne["id"],),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
entree["decisions_cc"] = [
|
||||||
|
dict(d)
|
||||||
|
for d in cx.execute(
|
||||||
|
"SELECT numero_affaire, date_saisine, date_decision, resultat, resume "
|
||||||
|
"FROM decisions_cc WHERE texte_id = ?",
|
||||||
|
(ligne["id"],),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
textes.append(entree)
|
||||||
|
|
||||||
|
charge = {
|
||||||
|
"genere_le": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"date_arrete_corpus": "2026-07-25",
|
||||||
|
"textes": textes,
|
||||||
|
"echeances": [
|
||||||
|
dict(e) for e in cx.execute("SELECT * FROM echeances ORDER BY date_echeance")
|
||||||
|
],
|
||||||
|
"insights": [dict(i) for i in cx.execute("SELECT * FROM insights ORDER BY numero")],
|
||||||
|
}
|
||||||
|
|
||||||
|
chemins.DUMP_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
chemins.DUMP_JSON.write_text(
|
||||||
|
json.dumps(charge, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return len(textes)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Ligne de commande
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def main() -> None:
|
||||||
|
analyseur = argparse.ArgumentParser(
|
||||||
|
description="Met à jour la base depuis les sources législatives officielles."
|
||||||
|
)
|
||||||
|
analyseur.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="collecte réelle, aucune écriture en base : affiche les écarts détectés",
|
||||||
|
)
|
||||||
|
analyseur.add_argument(
|
||||||
|
"--hors-ligne",
|
||||||
|
action="store_true",
|
||||||
|
help="n'utilise que le cache HTTP, sans accès réseau",
|
||||||
|
)
|
||||||
|
analyseur.add_argument("--verbeux", action="store_true")
|
||||||
|
arguments = analyseur.parse_args()
|
||||||
|
|
||||||
|
configurer("DEBUG" if arguments.verbeux else "INFO")
|
||||||
|
depart = time.monotonic()
|
||||||
|
|
||||||
|
cx = db.initialiser()
|
||||||
|
mode = "dry-run" if arguments.dry_run else "update"
|
||||||
|
|
||||||
|
with ClientHttp(hors_ligne=arguments.hors_ligne) as client:
|
||||||
|
collectes = collecter(cx, client)
|
||||||
|
|
||||||
|
rapport = comparer(cx, collectes)
|
||||||
|
rapport.mode = mode
|
||||||
|
for collecte in collectes:
|
||||||
|
if collecte.ignore:
|
||||||
|
rapport.alertes.append(f"{collecte.collecteur} ignoré : {collecte.motif_ignore}")
|
||||||
|
rapport.alertes.extend(f"{collecte.collecteur} : {e}" for e in collecte.erreurs)
|
||||||
|
|
||||||
|
run_id = db.ouvrir_run(cx, mode)
|
||||||
|
if not arguments.dry_run:
|
||||||
|
appliquer(cx, rapport, run_id)
|
||||||
|
exportes = exporter_json(cx)
|
||||||
|
log.info("dump statique régénéré", textes=exportes, fichier=str(chemins.DUMP_JSON))
|
||||||
|
|
||||||
|
db.cloturer_run(
|
||||||
|
cx,
|
||||||
|
run_id,
|
||||||
|
duree_s=time.monotonic() - depart,
|
||||||
|
ajouts=rapport.ajouts,
|
||||||
|
modifications=rapport.modifications,
|
||||||
|
echecs=sum(len(c.erreurs) for c in collectes),
|
||||||
|
alertes=rapport.alertes,
|
||||||
|
rapport=rapport.en_texte(),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(rapport.en_texte())
|
||||||
|
print()
|
||||||
|
if arguments.dry_run:
|
||||||
|
print(" Mode --dry-run : aucune écriture en base.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Vendored
+163
@@ -0,0 +1,163 @@
|
|||||||
|
<html><body><table class="saisine-list sticky-header responsive-enabled" data-striping="1">
|
||||||
|
<caption class="sr-only">Tableau des affaires en instance</caption>
|
||||||
|
|
||||||
|
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" specifier="field_numero_affaire" aria-sort="ascending" class="is-active"><a href="/decisions/affaires-en-instances?id=32246&sort=desc&order=Affaire%20n%C2%B0" title="trier par Affaire n°" rel="nofollow">Affaire n°<span class="tablesort tablesort--desc">
|
||||||
|
<span class="visually-hidden">
|
||||||
|
Trier par ordre décroissant
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a></th>
|
||||||
|
<th scope="col" specifier="field_disposition_loi"><a href="/decisions/affaires-en-instances?id=32246&sort=asc&order=Disposition" title="trier par Disposition" rel="nofollow">Disposition</a></th>
|
||||||
|
<th scope="col" specifier="field_auteur_saisine"><a href="/decisions/affaires-en-instances?id=32246&sort=asc&order=Auteur%20de%20la%20saisine" title="trier par Auteur de la saisine" rel="nofollow">Auteur de la saisine</a></th>
|
||||||
|
<th scope="col" specifier="field_dates_saisine"><a href="/decisions/affaires-en-instances?id=32246&sort=asc&order=Date%20de%20la%20saisine" title="trier par Date de la saisine" rel="nofollow">Date de la saisine</a></th>
|
||||||
|
<th scope="col" specifier="field_date_limite_reception"><a href="/decisions/affaires-en-instances?id=32246&sort=asc&order=Date%20de%20cl%C3%B4ture%20de%20l%27instruction" title="trier par Date de clôture de l'instruction" rel="nofollow">Date de clôture de l'instruction</a></th>
|
||||||
|
<th scope="col" specifier="field_decision_rendue"><a href="/decisions/affaires-en-instances?id=32246&sort=asc&order=Date%20de%20lecture%20de%20la%20d%C3%A9cision" title="trier par Date de lecture de la décision" rel="nofollow">Date de lecture de la décision</a></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr class="odd">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-907 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi actualisant la programmation militaire pour les années 2024 à 2030 et portant diverses dispositions intéressant la défense</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026907dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>06.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-06T00:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="even">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-910 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi relative au droit à l'aide à mourir</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinepdtsen.pdf" target="_blank" class="auteur"><p>Président du Sénat</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinesen_0.pdf" target="_blank" class="auteur"><p>Plus de soixante sénateurs</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinespm.pdf" target="_blank" class="auteur"><p>Premier ministre</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>16.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-16T09:00:00+0200</p>
|
||||||
|
<p>17.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-17T11:00:00+0200</p>
|
||||||
|
<p>17.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-17T12:00:00+0200</p>
|
||||||
|
<p>20.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-20T07:00:00+0200</p>
|
||||||
|
<p>20.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-20T08:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="odd">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-911 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi visant à protéger les mineurs des risques auxquels les expose l’utilisation des réseaux sociaux</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026911dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026911dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
|
||||||
|
<p>24.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-24T18:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="even">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-912 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi pour une montagne vivante et souveraine</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026912dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="odd">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-913 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi visant à moderniser la gestion du patrimoine immobilier de l’État</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026913dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="even">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-914 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi d'urgence pour la protection et la souveraineté agricoles</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026914dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026914dc_saisinedep_2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>24.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-24T14:00:00+0200</p>
|
||||||
|
<p>24.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-24T15:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="odd">
|
||||||
|
<td><div class="mobile-hide" data-column="Affaire n°">2026-915 DC<span class="arrow"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="mobile-hide" data-column="Disposition">Loi visant à offrir des réponses immédiates aux phénomènes troublant l’ordre public, la sécurité et la tranquillité de nos concitoyens</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026915dc_saisinedep1.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026915dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
|
||||||
|
</a></div>
|
||||||
|
</td>
|
||||||
|
<td><div class="date" data-column="Date de la saisine"><p>24.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-24T08:00:00+0200</p>
|
||||||
|
<p>24.07.2026</p>
|
||||||
|
<p class="cell-value hidden">2026-07-24T09:00:00+0200</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></body></html>
|
||||||
Vendored
+345
@@ -0,0 +1,345 @@
|
|||||||
|
<html><body><article class="landing landing--type-decisions landing--view-mode-full">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
|
||||||
|
<div class="field field--name-field-image-bandeau field--type-entity-reference field--label-hidden field__item"><article class="media media--type-image media--view-mode-bandeau-landing">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="field field--name-field-media-image field--type-image field--label-hidden field__item"> <picture>
|
||||||
|
<source srcset="/sites/default/files/styles/bandeau_landing_wide/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&itok=Wyn5lULp 1x" media="all and (min-width: 1024px)" type="image/webp" width="1350" height="550">
|
||||||
|
<source srcset="/sites/default/files/styles/bandeau_landing_narrow/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&itok=yKNGqhX1 1x" media="all and (min-width: 481px) and (max-width: 1023px)" type="image/webp" width="1024" height="417">
|
||||||
|
<source srcset="/sites/default/files/styles/bandeau_landing_mobile/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&itok=qFsmUyCr 1x" media="all and (max-width: 480px)" type="image/webp" width="480" height="196">
|
||||||
|
<img loading="lazy" aria-hidden="true" width="480" height="196" src="/sites/default/files/styles/bandeau_landing_mobile/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&itok=qFsmUyCr" alt="Bandeau de la landing Toutes les décisions">
|
||||||
|
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">Les décisions du Conseil Constitutionnel</h1>
|
||||||
|
|
||||||
|
<div class="clearfix text-formatted field field--name-field-texte field--type-text-long field--label-hidden field__item"><p>Toutes les décisions, ainsi que des outils statistiques et explicatifs pour mieux connaître l’activité du Conseil constitutionnel depuis sa création</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-decision field--type-link field--label-hidden field__item"><a href="/les-decisions/decisions-par-type">Comprendre les types de décisions</a></div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-affaires-instance field--type-link field--label-hidden field__item"><a href="/decisions/affaires-instances">Les affaires en instance</a></div>
|
||||||
|
<div class="formulaire-recherche-decisions"><form data-drupal-selector="cc-landing-recherche-decisions" action="/decisions" method="post" id="cc-landing-recherche-decisions" accept-charset="UTF-8">
|
||||||
|
<h3 class="titre-formulaire" data-drupal-selector="edit-titre">Rechercher une décision</h3>
|
||||||
|
<div class="js-form-item form-item js-form-type-search form-type-search js-form-item-textfield form-item-textfield">
|
||||||
|
<label for="edit-textfield">Rechercher un terme ou un numéro de décision</label>
|
||||||
|
<input list="autocomplete-list" data-drupal-selector="edit-textfield" type="search" id="edit-textfield" name="textfield" value="" size="60" maxlength="128" placeholder="Ex: égalité devant la loi" class="form-search">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<datalist id="autocomplete-list" data-drupal-selector="edit-autocomplete-list"></datalist>
|
||||||
|
<div class="js-form-item form-item js-form-type-select form-type-select js-form-item-types form-item-types form-no-label">
|
||||||
|
<select data-drupal-selector="edit-types" id="edit-types" name="types" class="form-select"><option value="all">Tous les types</option><option value="32205">Question prioritaire de constitutionnalité</option><option value="32246">Constitutionnalité des lois, traités, des règlements des Assemblées</option><option value="32797">Loi ordinaire</option><option value="32800">Loi organique</option><option value="32803">Traité</option><option value="32806">Règlement des assemblées</option><option value="32211">Élections à l'Assemblée nationale</option><option value="32220">Élections au Sénat</option><option value="32224">Loi du pays de Nouvelle-Calédonie</option><option value="32221">Compétences outre-mer</option><option value="32223">Déclassement</option><option value="32215">Incompatibilité des parlementaires</option><option value="32400">Obligations fiscales</option><option value="32216">Déchéance de parlementaires</option><option value="32222">Fins de non-recevoir</option><option value="32212">Avis de l'article 16</option><option value="32219">Élection présidentielle</option><option value="32214">Fonctionnement du Conseil constitutionnel</option><option value="32218">Référendum</option><option value="32403">Référendum d'initiative partagée</option><option value="32217">Divers élections</option><option value="32213">Autres textes et décisions</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="block-facets block-facet-date-range-picker js-form-wrapper form-wrapper" id="periode" data-drupal-selector="edit-date-range"><div class="form-group js-form-wrapper form-wrapper" data-drupal-selector="edit-form-group" id="edit-form-group"><label data-drupal-selector="edit-label">Par période</label>
|
||||||
|
<div class="js-form-item form-item js-form-type-date form-type-date js-form-item-date-from form-item-date-from form-no-label">
|
||||||
|
<span class="clear-input hidden">X</span>
|
||||||
|
<input id="edit-date-from" name="date_from" data-drupal-selector="edit-date-from" type="date" value="" class="form-date">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="js-form-item form-item js-form-type-date form-type-date js-form-item-date-to form-item-date-to form-no-label">
|
||||||
|
<span class="clear-input hidden">X</span>
|
||||||
|
<input id="edit-date-to" name="date_to" data-drupal-selector="edit-date-to" type="date" value="" class="form-date">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input data-drupal-selector="edit-submit" type="submit" id="edit-submit" name="op" value="Lancer une recherche" class="button js-form-submit form-submit">
|
||||||
|
<input class="reset reload-page button js-form-submit form-submit" data-drupal-selector="edit-reset" type="submit" id="edit-reset" name="op" value="Réinitialiser">
|
||||||
|
<a href="/recherche-experte" class="lien-page-recherche" data-smarttag="[]" data-drupal-selector="edit-lien" id="edit-lien">Plus de critères de recherche</a><input autocomplete="off" data-drupal-selector="form-if1ifd6wffso28jvmkzvnsozeco7wqt14mauqf8sdi" type="hidden" name="form_build_id" value="form-_IF1iFd6WfFSo28JVMkZvnSozEcO7wQt14Mauqf8sdI">
|
||||||
|
<input data-drupal-selector="edit-cc-landing-recherche-decisions" type="hidden" name="form_id" value="cc_landing_recherche_decisions">
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
<div class="sidebar">
|
||||||
|
<h3>Outils et ressources</h3>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
|
||||||
|
<div class="field field--name-field-date-analytique field--type-string field--label-above">
|
||||||
|
<div class="field__label">Les tables analytiques</div>
|
||||||
|
<div class="field__item">À jour au 1<sup>er</sup> septembre 2024</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-reference-analytique field--type-entity-reference field--label-hidden field__item"><article class="media media--type-file media--view-mode-ressource-decisions">
|
||||||
|
|
||||||
|
<a href="https://www.conseil-constitutionnel.fr/sites/default/files/2024-10/1958-202408_tables.pdf">Tables analytiques 1958-2024</a><div class="type-size"><span class="type">Pdf</span>
|
||||||
|
<span class="size">33.95 Mo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-analytique field--type-link field--label-hidden field__item"><a href="/les-decisions/tables-analytiques">En savoir plus</a></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-dispositions field--type-link field--label-above">
|
||||||
|
<div class="field__label">Recueil des dispositions validées</div>
|
||||||
|
<div class="field__item"><a href="/dispositions">Voir le recueil</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
|
||||||
|
<div class="field field--name-field-date-bilan field--type-string field--label-above">
|
||||||
|
<div class="field__label">Bilan statistique</div>
|
||||||
|
<div class="field__item">À jour au 30 juin 2025</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-bilan field--type-link field--label-hidden field__item"><a href="/bilan-statistique">Voir le bilan</a></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
|
||||||
|
<div class="field field--name-field-lien-archive-seance field--type-link field--label-above">
|
||||||
|
<div class="field__label">Archives des comptes rendus de séances</div>
|
||||||
|
<div class="field__item"><a href="/les-decisions/archives-des-comptes-rendus-de-seances">En savoir plus</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrapper-content">
|
||||||
|
<div class="wrapper-dernieres-decisions"><h2>Les dernières décisions</h2>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026908DC.htm" rel="bookmark" title="Décision n° 2026-908 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-908 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi organique relative au renforcement des juridictions criminelles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026909DC.htm" rel="bookmark" title="Décision n° 2026-909 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-909 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi sur la justice criminelle et le respect des victimes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Non conformité partielle - réserve</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026906DC.htm" rel="bookmark" title="Décision n° 2026-906 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-906 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques d’attentat</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité - réserve</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/20261213QPC.htm" rel="bookmark" title="Décision n° 2026-1213 QPC du 10 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-1213 QPC du 10 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Société Altaroc Partners et autres [Notification du droit de se taire à la personne à laquelle sont notifiés des griefs dans le cadre d'une procédure de sanction par l’Autorité des marchés financiers]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/20269LP.htm" rel="bookmark" title="Décision n° 2026-9 LP du 9 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-9 LP du 9 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi du pays modifiant le code minier de la Nouvelle-Calédonie (partie législative)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026326L.htm" rel="bookmark" title="Décision n° 2026-326 L du 9 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-326 L du 9 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Nature juridique de certaines dispositions de l’article L. 122-8 du code de l’énergie</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Réglementaire</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/20261211QPC.htm" rel="bookmark" title="Décision n° 2026-1211 QPC du 3 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-1211 QPC du 3 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>M. Maurice D. [Interdiction de l’inscription au tableau de l’ordre des médecins, chirurgiens dentistes et sages-femmes inscrits ou enregistrés dans un autre État]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/20261212QPC.htm" rel="bookmark" title="Décision n° 2026-1212 QPC du 3 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-1212 QPC du 3 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>M. Valeriu M. [Circonstances justifiant l’aggravation de la suspension prononcée par l’Agence française de lutte contre le dopage]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Non conformité totale</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026325L.htm" rel="bookmark" title="Décision n° 2026-325 L du 2 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-325 L du 2 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Nature juridique de certaines dispositions de l’article L. 1422-3 du code du travail et de l’article L. 131-3 du code de l’entrée et du séjour des étrangers et du droit d’asile</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Réglementaire</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/20261210QPC.htm" rel="bookmark" title="Décision n° 2026-1210 QPC du 25 juin 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-1210 QPC du 25 juin 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Société Orange SA [Cumul de poursuites et de sanctions en cas de manquement aux règles encadrant la prospection directe par voie automatisée]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Non conformité totale - effet différé - réserve transitoire</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<a href="/les-decisions" class="lien-toutes-decisions">Toutes les décisions</a>
|
||||||
|
</div></article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026908DC.htm" rel="bookmark" title="Décision n° 2026-908 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-908 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi organique relative au renforcement des juridictions criminelles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026909DC.htm" rel="bookmark" title="Décision n° 2026-909 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-909 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi sur la justice criminelle et le respect des victimes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Non conformité partielle - réserve</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
|
||||||
|
|
||||||
|
<a href="/decision/2026/2026906DC.htm" rel="bookmark" title="Décision n° 2026-906 DC du 23 juillet 2026">
|
||||||
|
<div class="node__content">
|
||||||
|
<h3 class="title">Décision n° 2026-906 DC du 23 juillet 2026</h3>
|
||||||
|
|
||||||
|
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
|
||||||
|
<p>Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques d’attentat</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
|
||||||
|
<p>Conformité - réserve</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article></body></html>
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<html><body><a href="/dossier-legislatif/pjl25-457.html">Loi n° 2026-650 du 23 juillet 2026 relative au renforcement des juridictions criminelles
|
||||||
|
parue au JO n°171 du 24 juillet 2026</a><a href="/dossier-legislatif/pjl25-456.html">Loi n° 2026-651 du 23 juillet 2026 sur la justice criminelle et le respect des victimes
|
||||||
|
parue au JO n°171 du 24 juillet 2026</a><a href="/dossier-legislatif/ppl25-214.html">Loi n° 2026-630 du 13 juillet 2026 visant à assurer le droit de chaque enfant à être assisté d'un avocat dans le cadre d'une mesure d'assistance éducative et de protection de l'enfance
|
||||||
|
parue au JO n°163 du 14 juillet 2026</a><a href="/dossier-legislatif/ppl23-431.html">Loi n° 2026-602 du 8 juillet 2026 visant à réduire l'impact environnemental de l'industrie textile
|
||||||
|
parue au JO n°159 du 9 juillet 2026</a><a href="/dossier-legislatif/pjl25-283.html">Loi n° 2026-574 du 30 juin 2026 portant habilitation de l'assemblée de Martinique à fixer des règles applicables sur son territoire en application de l'article 73 de la Constitution en matière d'énergie, d'eau et d'assainissement
|
||||||
|
parue au JO n°152 du 1 juillet 2026</a><a href="/dossier-legislatif/ppl25-359.html">Loi n° 2026-554 du 29 juin 2026 visant à relancer les investissements dans le secteur de l'hydroélectricité pour contribuer à la transition énergétique
|
||||||
|
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl25-320.html">Loi n° 2026-555 du 29 juin 2026 visant à réparer les préjudices causés par la transplantation de mineurs de La Réunion en France hexagonale de 1962 à 1984
|
||||||
|
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl25-273.html">Loi n° 2026-553 du 29 juin 2026 visant à améliorer l'accès au logement des travailleurs des services publics
|
||||||
|
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl24-008.html">Loi n° 2026-542 du 26 juin 2026 relative à la sortie des collections publiques de restes humains kali'nas et arawaks en vue de funérailles sur le territoire de la Guyane
|
||||||
|
parue au JO n°149 du 27 juin 2026</a><a href="/dossier-legislatif/pjl25-024.html">Loi n° 2026-534 du 25 juin 2026 relative à la lutte contre les fraudes sociales et fiscales
|
||||||
|
parue au JO n°148 du 26 juin 2026</a><a href="/dossier-legislatif/ppl23-483.html">Loi n° 2026-532 du 24 juin 2026 visant à ouvrir le dispositif de réduction d'activité aux moniteurs de ski stagiaires
|
||||||
|
parue au JO n°147 du 25 juin 2026</a><a href="/dossier-legislatif/ppl24-180.html">Loi n° 2026-492 du 12 juin 2026 visant à améliorer la protection et l'accompagnement des parents d'enfants atteints d'un cancer, d'une maladie grave ou d'un handicap
|
||||||
|
parue au JO n°137 du 13 juin 2026</a><a href="/dossier-legislatif/ppl23-373.html">Loi n° 2026-491 du 12 juin 2026 visant à reconnaître la responsabilité de l'État et à indemniser les victimes du chlordécone
|
||||||
|
parue au JO n°137 du 13 juin 2026</a><a href="/dossier-legislatif/pjl25-470.html">Loi n° 2026-470 du 11 juin 2026 portant transposition de l'avenant n° 3 du 25 février 2026 au protocole d'accord du 10 novembre 2023 relatif à l'assurance chômage
|
||||||
|
parue au JO n°136 du 12 juin 2026</a><a href="/dossier-legislatif/ppl25-213.html">Loi n° 2026-442 du 4 juin 2026 visant à étendre à toutes les communes la compensation financière prévue pour les communes de plus de 3 500 habitants pour l'exercice de l'ensemble des compétences du service public de la petite enfance
|
||||||
|
parue au JO n°130 du 5 juin 2026</a><a href="/dossier-legislatif/ppl24-475.html">Loi n° 2026-441 du 4 juin 2026 portant pérennisation du contrat de professionnalisation expérimental
|
||||||
|
parue au JO n°130 du 5 juin 2026</a><a href="/dossier-legislatif/ppl24-636.html">Loi n° 2026-410 du 28 mai 2026 organique portant régularisation des natifs dans le corps électoral pour les élections au congrès et aux assemblées de province de Nouvelle-Calédonie
|
||||||
|
parue au JO n°124 du 29 mai 2026</a><a href="/dossier-legislatif/pjl23-550.html">Loi n° 2026-403 du 26 mai 2026 de simplification de la vie économique
|
||||||
|
parue au JO n°122 du 27 mai 2026</a><a href="/dossier-legislatif/ppl24-662.html">Loi n° 2026-404 du 26 mai 2026 visant à garantir l'égal accès de tous à l'accompagnement et aux soins palliatifs
|
||||||
|
parue au JO n°122 du 27 mai 2026</a><a href="/dossier-legislatif/ppl24-226.html">Loi n° 2026-381 du 19 mai 2026 visant à soutenir les collectivités territoriales dans la prévention et la gestion des inondations
|
||||||
|
parue au JO n°117 du 20 mai 2026</a><a href="/dossier-legislatif/ppl25-401.html">Loi n° 2026-373 du 15 mai 2026 facilitant l'exercice en France des médecins diplômés au Royaume-Uni ayant débuté leurs études avant le Brexit
|
||||||
|
parue au JO n°114 du 16 mai 2026</a><a href="/dossier-legislatif/pjl24-871.html">Loi n° 2026-351 du 9 mai 2026 relatif à la restitution de biens culturels ayant fait l'objet d'une appropriation illicite
|
||||||
|
parue au JO n°109 du 10 mai 2026</a><a href="/dossier-legislatif/ppl24-637.html">Loi n° 2026-350 du 9 mai 2026 visant à garantir le droit de visite des parlementaires et des bâtonniers dans les lieux de privation de liberté
|
||||||
|
parue au JO n°109 du 10 mai 2026</a><a href="/dossier-legislatif/ppl25-187.html">Loi n° 2026-307 du 23 avril 2026 visant à instaurer une procédure simplifiée de recouvrement des créances commerciales incontestées
|
||||||
|
parue au JO n°97 du 24 avril 2026</a><a href="/dossier-legislatif/ppl24-415.html">Loi n° 2026-248 du 7 avril 2026 visant à simplifier la sortie de l'indivision et la gestion des successions vacantes
|
||||||
|
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/ppl25-328.html">Loi n° 2026-249 du 7 avril 2026 visant à permettre le remboursement des frais d'expertise comptable aux candidats
|
||||||
|
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/ppl24-413.html">Loi n° 2026-247 du 7 avril 2026 relative aux missions des professionnels de santé, vétérinaires, psychothérapeutes et psychologues des services d'incendie et de secours
|
||||||
|
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/pjl24-630.html">Loi n° 2026-201 du 20 mars 2026 relative à l'organisation des jeux Olympiques et Paralympiques de 2030
|
||||||
|
parue au JO n°69 du 21 mars 2026</a><a href="/dossier-legislatif/ppl23-569.html">Loi n° 2026-122 du 23 février 2026 relative à la confidentialité des consultations des juristes d'entreprise
|
||||||
|
parue au JO n°48 du 25 février 2026</a><a href="/dossier-legislatif/pjlf2026.html">Loi n° 2026-103 du 19 février 2026 de finances pour 2026
|
||||||
|
parue au JO n°43 du 20 février 2026</a><a href="/dossier-legislatif/ppl24-223.html">Loi n° 2026-6 du 7 janvier 2026 organique tendant à modifier le II de l'article 43 de la loi organique n° 2004-192 du 27 février 2004 portant statut d'autonomie de la Polynésie française
|
||||||
|
parue au JO n°6 du 8 janvier 2026</a></body></html>
|
||||||
@@ -212,7 +212,10 @@ def test_une_fiche_a_timeline_et_sources(base_complete) -> None:
|
|||||||
|
|
||||||
def test_les_deductions_sont_signalees(base_complete) -> None:
|
def test_les_deductions_sont_signalees(base_complete) -> None:
|
||||||
"""Toute valeur déduite porte un motif de vérification lisible."""
|
"""Toute valeur déduite porte un motif de vérification lisible."""
|
||||||
for ligne in base_complete.execute("SELECT id, motif_verification FROM textes WHERE a_verifier = 1"):
|
lignes = base_complete.execute(
|
||||||
|
"SELECT id, motif_verification FROM textes WHERE a_verifier = 1"
|
||||||
|
)
|
||||||
|
for ligne in lignes:
|
||||||
assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif"
|
assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Collecteurs et rapprochement.
|
||||||
|
|
||||||
|
Les parseurs sont testés sur des fixtures HTML figées, capturées le 25 juillet
|
||||||
|
2026 sur les sites officiels : aucun test ne dépend du réseau. Une fixture qui
|
||||||
|
cesse de passer signale un changement de structure du site — c'est le but.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
from pipeline.collecteurs.conseil_constitutionnel import (
|
||||||
|
_index_des_colonnes,
|
||||||
|
_lire_decision_rendue,
|
||||||
|
_lire_ligne_instance,
|
||||||
|
)
|
||||||
|
from pipeline.collecteurs.http import ClientHttp, _cle_de_cache
|
||||||
|
from pipeline.collecteurs.legifrance import CollecteurLegifrance, _lire_titre
|
||||||
|
from pipeline.collecteurs.senat import CollecteurSenat
|
||||||
|
from pipeline.modeles import ResultatCC, Statut, TypeTexte
|
||||||
|
from pipeline.rapprochement import Issue, normaliser, rapprocher, similarite
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Conseil constitutionnel
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def affaires_dc() -> list:
|
||||||
|
tableau = HTMLParser(
|
||||||
|
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
|
||||||
|
).css_first("table")
|
||||||
|
colonnes = _index_des_colonnes(tableau)
|
||||||
|
return [
|
||||||
|
affaire
|
||||||
|
for ligne in tableau.css("tbody tr")
|
||||||
|
if (affaire := _lire_ligne_instance(ligne, colonnes))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_sept_affaires_dc_en_instance(affaires_dc) -> None:
|
||||||
|
"""Le corpus en annonçait sept ; le registre officiel les confirme."""
|
||||||
|
numeros = {a.numero_affaire for a in affaires_dc}
|
||||||
|
assert numeros == {
|
||||||
|
"2026-907 DC",
|
||||||
|
"2026-910 DC",
|
||||||
|
"2026-911 DC",
|
||||||
|
"2026-912 DC",
|
||||||
|
"2026-913 DC",
|
||||||
|
"2026-914 DC",
|
||||||
|
"2026-915 DC",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_saisine_de_la_loi_riposte(affaires_dc) -> None:
|
||||||
|
riposte = next(a for a in affaires_dc if a.numero_affaire == "2026-915 DC")
|
||||||
|
assert riposte.date_saisine == date(2026, 7, 24)
|
||||||
|
assert riposte.resultat is ResultatCC.EN_INSTANCE
|
||||||
|
assert "soixante députés" in (riposte.saisissants or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_programmation_militaire_recoit_son_numero(affaires_dc) -> None:
|
||||||
|
"""Le rapport la citait sans numéro d'affaire ; la collecte le fournit."""
|
||||||
|
lpm = next(a for a in affaires_dc if a.numero_affaire == "2026-907 DC")
|
||||||
|
assert lpm.date_saisine == date(2026, 7, 6)
|
||||||
|
assert "programmation militaire" in (lpm.resume or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_colonnes_lues_par_entete_et_non_par_position() -> None:
|
||||||
|
"""Les tableaux DC et QPC n'ont pas les mêmes colonnes."""
|
||||||
|
tableau = HTMLParser(
|
||||||
|
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
|
||||||
|
).css_first("table")
|
||||||
|
colonnes = _index_des_colonnes(tableau)
|
||||||
|
assert colonnes["saisissants"] != colonnes["date_saisine"]
|
||||||
|
assert set(colonnes) >= {"affaire", "disposition", "saisissants", "date_saisine"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("intitule", "attendu"),
|
||||||
|
[
|
||||||
|
("2026-908 DC du 23 juillet 2026 Loi organique … Conformité", ResultatCC.CONFORME),
|
||||||
|
(
|
||||||
|
"2026-906 DC du 23 juillet 2026 Loi … Conformité - réserve",
|
||||||
|
ResultatCC.CONFORME_AVEC_RESERVES,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-909 DC du 23 juillet 2026 Loi … Non conformité partielle",
|
||||||
|
ResultatCC.NON_CONFORMITE_PARTIELLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lecture_du_resultat_d_une_decision(intitule: str, attendu: ResultatCC) -> None:
|
||||||
|
"""« Conformité » ne contient pas « conforme » : l'ordre des motifs compte."""
|
||||||
|
decision = _lire_decision_rendue(f"Décision n° {intitule}", None)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.resultat is attendu
|
||||||
|
assert decision.date_decision == date(2026, 7, 23)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decisions_rendues_de_la_fixture() -> None:
|
||||||
|
document = HTMLParser((FIXTURES / "cc_decisions.html").read_text(encoding="utf-8"))
|
||||||
|
decisions = [
|
||||||
|
d
|
||||||
|
for article in document.css("article")
|
||||||
|
if (d := _lire_decision_rendue(" ".join(article.text(separator=" ").split()), None))
|
||||||
|
]
|
||||||
|
numeros = {d.numero_affaire for d in decisions}
|
||||||
|
assert {"2026-906 DC", "2026-908 DC", "2026-909 DC"} <= numeros
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Sénat
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def lois_senat() -> list:
|
||||||
|
document = HTMLParser(
|
||||||
|
(FIXTURES / "senat_lois_promulguees.html").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
collecteur = CollecteurSenat(ClientHttp(hors_ligne=True))
|
||||||
|
textes = []
|
||||||
|
for ancre in document.css("a"):
|
||||||
|
libelle = " ".join(ancre.text(separator=" ", strip=True).split())
|
||||||
|
if texte := collecteur._lire_intitule(libelle, ancre.attributes.get("href")):
|
||||||
|
textes.append(texte)
|
||||||
|
return textes
|
||||||
|
|
||||||
|
|
||||||
|
def test_senat_releve_les_lois_de_la_fenetre(lois_senat) -> None:
|
||||||
|
numeros = {t.numero_officiel for t in lois_senat}
|
||||||
|
# Quatre lois de la fenêtre de veille, aux dates connues du corpus.
|
||||||
|
assert {"2026-651", "2026-650", "2026-630", "2026-602"} <= numeros
|
||||||
|
|
||||||
|
|
||||||
|
def test_senat_lit_dates_et_nature(lois_senat) -> None:
|
||||||
|
par_numero = {t.numero_officiel: t for t in lois_senat}
|
||||||
|
|
||||||
|
justice = par_numero["2026-651"]
|
||||||
|
assert justice.date_promulgation == date(2026, 7, 23)
|
||||||
|
assert justice.statut is Statut.PROMULGUEE
|
||||||
|
assert justice.type is TypeTexte.LOI
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_deux_sources_ne_disent_pas_la_meme_chose_du_caractere_organique(
|
||||||
|
lois_senat,
|
||||||
|
) -> None:
|
||||||
|
"""Asymétrie réelle entre les sources, à ne pas corriger en silence.
|
||||||
|
|
||||||
|
Légifrance intitule la loi n° 2026-650 « LOI organique n° 2026-650 … » ;
|
||||||
|
la liste du Sénat écrit « Loi n° 2026-650 … » sans la qualifier. Le
|
||||||
|
collecteur rapporte ce que sa source dit — c'est le rôle du rapprochement
|
||||||
|
de ne pas laisser la source la plus pauvre écraser la plus riche. Le run de
|
||||||
|
mise à jour ne compare d'ailleurs pas le champ `type`.
|
||||||
|
"""
|
||||||
|
organique = par_numero_senat(lois_senat)["2026-650"]
|
||||||
|
assert organique.type is TypeTexte.LOI, (
|
||||||
|
"le Sénat ne qualifie pas cette loi d'organique : le collecteur ne doit "
|
||||||
|
"pas le deviner à sa place"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def par_numero_senat(lois: list) -> dict:
|
||||||
|
return {t.numero_officiel: t for t in lois}
|
||||||
|
|
||||||
|
|
||||||
|
def test_senat_ne_confond_pas_l_objet_avec_l_etat(lois_senat) -> None:
|
||||||
|
"""Le libellé se termine par « parue » : ce n'est pas l'objet de la loi."""
|
||||||
|
for texte in lois_senat:
|
||||||
|
assert not texte.titre.rstrip().endswith("parue")
|
||||||
|
assert "en cours" not in texte.titre
|
||||||
|
|
||||||
|
|
||||||
|
def test_senat_produit_une_source_primaire(lois_senat) -> None:
|
||||||
|
for texte in lois_senat:
|
||||||
|
assert texte.sources
|
||||||
|
assert texte.sources[0].tier == "T1"
|
||||||
|
assert texte.sources[0].url.startswith("https://www.senat.fr")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Légifrance
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_legifrance_indisponible_sans_identifiants(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("LEGIFRANCE_CLIENT_ID", raising=False)
|
||||||
|
collecteur = CollecteurLegifrance(ClientHttp(hors_ligne=True))
|
||||||
|
disponible, motif = collecteur.est_disponible()
|
||||||
|
assert disponible is False
|
||||||
|
assert "repli" in (motif or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_legifrance_lit_un_resultat_de_recherche() -> None:
|
||||||
|
titre = {
|
||||||
|
"id": "LEGITEXT000054249605_14-06-2026",
|
||||||
|
"cid": "JORFTEXT000054245243",
|
||||||
|
"title": "LOI n° 2026-491 du 12 juin 2026 visant à reconnaître la responsabilité de l'Etat",
|
||||||
|
}
|
||||||
|
texte = _lire_titre(titre, "2026-491")
|
||||||
|
assert texte is not None
|
||||||
|
assert texte.date_promulgation == date(2026, 6, 12)
|
||||||
|
assert texte.source_url.endswith("JORFTEXT000054245243")
|
||||||
|
assert texte.sources[0].tier == "T1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legifrance_ecarte_un_resultat_hors_sujet() -> None:
|
||||||
|
titre = {"cid": "X", "title": "LOI n° 2026-999 du 1er janvier 2026 sans rapport"}
|
||||||
|
assert _lire_titre(titre, "2026-491") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legifrance_deduit_le_caractere_organique() -> None:
|
||||||
|
titre = {
|
||||||
|
"cid": "JORF1",
|
||||||
|
"title": "LOI organique n° 2026-650 du 23 juillet 2026 relative aux juridictions",
|
||||||
|
}
|
||||||
|
texte = _lire_titre(titre, "2026-650")
|
||||||
|
assert texte.type is TypeTexte.LOI_ORGANIQUE
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Client HTTP
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_le_cache_distingue_les_corps_de_requete() -> None:
|
||||||
|
a = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-491"}, None)
|
||||||
|
b = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-403"}, None)
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_hors_ligne_ne_touche_pas_au_reseau(tmp_path) -> None:
|
||||||
|
client = ClientHttp(cache=tmp_path, hors_ligne=True)
|
||||||
|
reponse = client.get("https://exemple.invalide/rien")
|
||||||
|
assert reponse.code == 0
|
||||||
|
assert not reponse.a_reussi
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_cache_est_relu(tmp_path) -> None:
|
||||||
|
client = ClientHttp(cache=tmp_path, hors_ligne=True)
|
||||||
|
cle = _cle_de_cache("GET", "https://x.fr/a", None, None)
|
||||||
|
client._ecrire_cache(cle, "contenu")
|
||||||
|
reponse = client.get("https://x.fr/a")
|
||||||
|
assert reponse.texte == "contenu"
|
||||||
|
assert reponse.depuis_le_cache
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Rapprochement
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_normalisation_retire_le_vocabulaire_passe_partout() -> None:
|
||||||
|
assert "loi" not in normaliser("LOI n° 2026-491 relative au chlordécone")
|
||||||
|
assert "chlordecone" in normaliser("LOI n° 2026-491 relative au chlordécone")
|
||||||
|
|
||||||
|
|
||||||
|
def test_similarite_reconnait_un_titre_court_dans_un_titre_long() -> None:
|
||||||
|
court = "Loi Riposte ordre public sécurité tranquillité"
|
||||||
|
long = (
|
||||||
|
"Loi visant à offrir des réponses immédiates aux phénomènes troublant "
|
||||||
|
"l'ordre public, la sécurité et la tranquillité de nos concitoyens"
|
||||||
|
)
|
||||||
|
assert similarite(court, long) >= 0.72
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_numero_officiel_prime_sur_le_titre() -> None:
|
||||||
|
correspondance = rapprocher(
|
||||||
|
titre="Un intitulé totalement différent",
|
||||||
|
numero_officiel="2026-491",
|
||||||
|
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
|
||||||
|
)
|
||||||
|
assert correspondance.issue is Issue.RAPPROCHE
|
||||||
|
assert correspondance.texte_id == "loi-2026-491"
|
||||||
|
assert correspondance.score == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_texte_inconnu_est_declare_nouveau() -> None:
|
||||||
|
correspondance = rapprocher(
|
||||||
|
titre="Loi sur les moniteurs de ski stagiaires en zone de montagne",
|
||||||
|
numero_officiel=None,
|
||||||
|
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
|
||||||
|
)
|
||||||
|
assert correspondance.issue is Issue.NOUVEAU
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_correspondance_douteuse_est_signalee_pas_fusionnee() -> None:
|
||||||
|
"""Entre les deux seuils, le pipeline s'abstient et demande un humain."""
|
||||||
|
correspondance = rapprocher(
|
||||||
|
titre="Loi relative à la protection des enfants et des mineurs",
|
||||||
|
numero_officiel=None,
|
||||||
|
candidats=[("pjl-protection-enfants", "PJL relatif à la protection des enfants", None)],
|
||||||
|
)
|
||||||
|
assert correspondance.issue in (Issue.RAPPROCHE, Issue.A_SIGNALER)
|
||||||
|
if correspondance.issue is Issue.A_SIGNALER:
|
||||||
|
assert "vérification humaine" in correspondance.motif
|
||||||
|
|
||||||
|
|
||||||
|
def test_aucun_candidat() -> None:
|
||||||
|
correspondance = rapprocher(titre="X", numero_officiel="2026-1", candidats=[])
|
||||||
|
assert correspondance.issue is Issue.NOUVEAU
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""Run de veille : détection des écarts, classification, export.
|
||||||
|
|
||||||
|
Aucun test ne va sur le réseau : les collectes sont fabriquées à la main, ce
|
||||||
|
qui permet de vérifier exactement quels écarts le pipeline doit voir — et
|
||||||
|
surtout lesquels il ne doit pas inventer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline import db
|
||||||
|
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
|
||||||
|
from pipeline.modeles import (
|
||||||
|
Confiance,
|
||||||
|
DecisionCC,
|
||||||
|
Evenement,
|
||||||
|
Pertinence,
|
||||||
|
ResultatCC,
|
||||||
|
Source,
|
||||||
|
Statut,
|
||||||
|
Texte,
|
||||||
|
TypeEtape,
|
||||||
|
TypeTexte,
|
||||||
|
)
|
||||||
|
from pipeline.update import appliquer, classer_nouveau, comparer, exporter_json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base(tmp_path) -> sqlite3.Connection:
|
||||||
|
"""Base minimale : une loi promulguée et une loi en attente de décision."""
|
||||||
|
cx = db.initialiser(tmp_path / "veille.db")
|
||||||
|
db.enregistrer_textes(
|
||||||
|
cx,
|
||||||
|
[
|
||||||
|
Texte(
|
||||||
|
id="loi-2026-491",
|
||||||
|
numero_officiel="2026-491",
|
||||||
|
type=TypeTexte.LOI,
|
||||||
|
titre_court="Chlordécone : responsabilité de l'État",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date(2026, 6, 12),
|
||||||
|
guadeloupe_pertinence=Pertinence.FORTE,
|
||||||
|
sources=[
|
||||||
|
Source(
|
||||||
|
url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
|
||||||
|
editeur="Légifrance",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
evenements=[
|
||||||
|
Evenement(
|
||||||
|
date_evenement=date(2026, 6, 12),
|
||||||
|
type_etape=TypeEtape.PROMULGATION,
|
||||||
|
description="Promulgation",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Texte(
|
||||||
|
id="loi-riposte",
|
||||||
|
type=TypeTexte.LOI,
|
||||||
|
titre_court="Loi « Riposte » (ordre public, sécurité, tranquillité)",
|
||||||
|
statut=Statut.ADOPTEE_NON_PROMULGUEE,
|
||||||
|
date_adoption=date(2026, 7, 21),
|
||||||
|
sources=[Source(url="https://www.assemblee-nationale.fr/dyn/17/textes/l17t0340")],
|
||||||
|
decisions_cc=[
|
||||||
|
DecisionCC(
|
||||||
|
numero_affaire="2026-915 DC",
|
||||||
|
date_saisine=date(2026, 7, 24),
|
||||||
|
resultat=ResultatCC.EN_INSTANCE,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return cx
|
||||||
|
|
||||||
|
|
||||||
|
def _collecte(**kwargs) -> ResultatCollecte:
|
||||||
|
return ResultatCollecte(collecteur="essai", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Détection des écarts
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_aucun_ecart_quand_la_source_confirme_la_base(base) -> None:
|
||||||
|
"""Le cas normal : la veille ne doit rien signaler quand rien ne bouge."""
|
||||||
|
collecte = _collecte(
|
||||||
|
textes=[
|
||||||
|
TexteCollecte(
|
||||||
|
titre="LOI n° 2026-491 du 12 juin 2026 chlordécone",
|
||||||
|
source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
|
||||||
|
collecteur="essai",
|
||||||
|
numero_officiel="2026-491",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date(2026, 6, 12),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
assert rapport.ecarts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_texte_inconnu_est_un_ajout(base) -> None:
|
||||||
|
collecte = _collecte(
|
||||||
|
textes=[
|
||||||
|
TexteCollecte(
|
||||||
|
titre="LOI n° 2026-700 du 1er septembre 2026 relative aux transports scolaires",
|
||||||
|
source_url="https://www.legifrance.gouv.fr/jorf/id/X",
|
||||||
|
collecteur="essai",
|
||||||
|
numero_officiel="2026-700",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date(2026, 9, 1),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
assert rapport.ajouts == 1
|
||||||
|
assert rapport.ecarts[0].nature == "ajout"
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_changement_de_statut_est_detecte(base) -> None:
|
||||||
|
"""La promulgation de la loi « Riposte » : l'événement que la veille attend."""
|
||||||
|
collecte = _collecte(
|
||||||
|
textes=[
|
||||||
|
TexteCollecte(
|
||||||
|
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
|
||||||
|
source_url="https://www.legifrance.gouv.fr/jorf/id/Y",
|
||||||
|
collecteur="essai",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date(2026, 8, 26),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
statuts = [e for e in rapport.ecarts if e.nature == "statut"]
|
||||||
|
assert len(statuts) == 1
|
||||||
|
assert statuts[0].texte_id == "loi-riposte"
|
||||||
|
assert statuts[0].ancienne_valeur == "adoptee_non_promulguee"
|
||||||
|
assert statuts[0].nouvelle_valeur == "promulguee"
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_decision_rendue_est_detectee(base) -> None:
|
||||||
|
collecte = _collecte(
|
||||||
|
decisions_cc=[
|
||||||
|
DecisionCC(
|
||||||
|
numero_affaire="2026-915 DC",
|
||||||
|
date_decision=date(2026, 8, 24),
|
||||||
|
resultat=ResultatCC.NON_CONFORMITE_PARTIELLE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
natures = {e.champ for e in rapport.ecarts if e.nature == "decision_cc"}
|
||||||
|
assert any("date_decision" in (c or "") for c in natures)
|
||||||
|
assert any("resultat" in (c or "") for c in natures)
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_affaire_inconnue_est_signalee(base) -> None:
|
||||||
|
"""Cas réel : le corpus ignorait le numéro 2026-907 DC de la LPM."""
|
||||||
|
collecte = _collecte(
|
||||||
|
decisions_cc=[
|
||||||
|
DecisionCC(
|
||||||
|
numero_affaire="2026-907 DC",
|
||||||
|
date_saisine=date(2026, 7, 6),
|
||||||
|
resume="Loi actualisant la programmation militaire",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
assert any("affaire inconnue" in e.description for e in rapport.ecarts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_affaire_en_instance_deja_connue_ne_produit_rien(base) -> None:
|
||||||
|
collecte = _collecte(
|
||||||
|
decisions_cc=[
|
||||||
|
DecisionCC(
|
||||||
|
numero_affaire="2026-915 DC",
|
||||||
|
date_saisine=date(2026, 7, 24),
|
||||||
|
resultat=ResultatCC.EN_INSTANCE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert comparer(base, [collecte]).ecarts == []
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Écriture
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_dry_run_n_ecrit_rien(base) -> None:
|
||||||
|
"""`comparer` ne doit jamais toucher à la base."""
|
||||||
|
avant = db.statistiques(base)
|
||||||
|
collecte = _collecte(
|
||||||
|
textes=[
|
||||||
|
TexteCollecte(
|
||||||
|
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
|
||||||
|
source_url="https://x.fr/y",
|
||||||
|
collecteur="essai",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
comparer(base, [collecte])
|
||||||
|
assert db.statistiques(base) == avant
|
||||||
|
|
||||||
|
|
||||||
|
def test_appliquer_ecrit_le_changement_et_le_journalise(base) -> None:
|
||||||
|
collecte = _collecte(
|
||||||
|
textes=[
|
||||||
|
TexteCollecte(
|
||||||
|
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
|
||||||
|
source_url="https://x.fr/y",
|
||||||
|
collecteur="essai",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rapport = comparer(base, [collecte])
|
||||||
|
run_id = db.ouvrir_run(base, "update")
|
||||||
|
appliquer(base, rapport, run_id)
|
||||||
|
|
||||||
|
statut = base.execute("SELECT statut FROM textes WHERE id = 'loi-riposte'").fetchone()[0]
|
||||||
|
assert statut == "promulguee"
|
||||||
|
|
||||||
|
journal = base.execute(
|
||||||
|
"SELECT nature, champ, nouvelle_valeur FROM veille_changements WHERE run_id = ?",
|
||||||
|
(run_id,),
|
||||||
|
).fetchall()
|
||||||
|
assert any(
|
||||||
|
ligne["champ"] == "statut" and ligne["nouvelle_valeur"] == "promulguee"
|
||||||
|
for ligne in journal
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Classification d'un texte nouveau
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_texte_collecte_est_marque_comme_automatique() -> None:
|
||||||
|
"""§5.3 : la machine propose avec une confiance basse, l'humain valide."""
|
||||||
|
texte = classer_nouveau(
|
||||||
|
TexteCollecte(
|
||||||
|
titre="LOI n° 2026-700 du 1er septembre 2026 relative à l'eau en Guadeloupe",
|
||||||
|
source_url="https://www.legifrance.gouv.fr/jorf/id/Z",
|
||||||
|
collecteur="legifrance",
|
||||||
|
numero_officiel="2026-700",
|
||||||
|
statut=Statut.PROMULGUEE,
|
||||||
|
date_promulgation=date(2026, 9, 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert texte.id == "loi-2026-700"
|
||||||
|
assert texte.confiance is Confiance.LOW
|
||||||
|
assert texte.a_verifier is True
|
||||||
|
assert "déduits" in (texte.motif_verification or "")
|
||||||
|
assert texte.guadeloupe_pertinence is Pertinence.FORTE # « Guadeloupe » dans l'intitulé
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_texte_sans_numero_recoit_un_identifiant_lisible() -> None:
|
||||||
|
texte = classer_nouveau(
|
||||||
|
TexteCollecte(
|
||||||
|
titre="Proposition de loi relative aux sargasses en Martinique",
|
||||||
|
source_url="https://www.senat.fr/x",
|
||||||
|
collecteur="senat",
|
||||||
|
statut=Statut.NAVETTE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert texte.id and " " not in texte.id
|
||||||
|
assert "sargasses" in texte.id
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Export statique
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_json(base, tmp_path, monkeypatch) -> None:
|
||||||
|
from pipeline import chemins
|
||||||
|
|
||||||
|
destination = tmp_path / "textes.json"
|
||||||
|
monkeypatch.setattr(chemins, "DUMP_JSON", destination)
|
||||||
|
|
||||||
|
total = exporter_json(base)
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
charge = json.loads(destination.read_text(encoding="utf-8"))
|
||||||
|
assert charge["date_arrete_corpus"] == "2026-07-25"
|
||||||
|
identifiants = {t["id"] for t in charge["textes"]}
|
||||||
|
assert identifiants == {"loi-2026-491", "loi-riposte"}
|
||||||
|
|
||||||
|
chlordecone = next(t for t in charge["textes"] if t["id"] == "loi-2026-491")
|
||||||
|
assert chlordecone["sources"][0]["url"].startswith("https://www.legifrance.gouv.fr")
|
||||||
|
assert chlordecone["evenements"][0]["type_etape"] == "promulgation"
|
||||||
|
|
||||||
|
riposte = next(t for t in charge["textes"] if t["id"] == "loi-riposte")
|
||||||
|
assert riposte["decisions_cc"][0]["numero_affaire"] == "2026-915 DC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rapport_lisible(base) -> None:
|
||||||
|
rapport = comparer(base, [_collecte()])
|
||||||
|
rapport.mode = "dry-run"
|
||||||
|
texte = rapport.en_texte()
|
||||||
|
assert "Rapport de veille" in texte
|
||||||
|
assert "conforme aux sources officielles" in texte
|
||||||
Reference in New Issue
Block a user