diff --git a/.env.example b/.env.example index b9e66f5..0ca9346 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,22 @@ # ── API Légifrance (PISTE) ─────────────────────────────────────────────────── # 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é # (repli sur le scraping léger AN / Sénat / Conseil constitutionnel). LEGIFRANCE_CLIENT_ID= LEGIFRANCE_CLIENT_SECRET= # `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 +# Clé d'API PISTE. Facultative : l'API Légifrance se contente du jeton OAuth. +LEGIFRANCE_API_KEY= # ── Comportement du pipeline ───────────────────────────────────────────────── # Chemin de la base SQLite (relatif à la racine du dépôt). diff --git a/pipeline/collecteurs/__init__.py b/pipeline/collecteurs/__init__.py new file mode 100644 index 0000000..3fb00fd --- /dev/null +++ b/pipeline/collecteurs/__init__.py @@ -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"] diff --git a/pipeline/collecteurs/base.py b/pipeline/collecteurs/base.py new file mode 100644 index 0000000..0868a13 --- /dev/null +++ b/pipeline/collecteurs/base.py @@ -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 diff --git a/pipeline/collecteurs/conseil_constitutionnel.py b/pipeline/collecteurs/conseil_constitutionnel.py new file mode 100644 index 0000000..33dfa27 --- /dev/null +++ b/pipeline/collecteurs/conseil_constitutionnel.py @@ -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 diff --git a/pipeline/collecteurs/http.py b/pipeline/collecteurs/http.py new file mode 100644 index 0000000..ca00d3e --- /dev/null +++ b/pipeline/collecteurs/http.py @@ -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 diff --git a/pipeline/collecteurs/legifrance.py b/pipeline/collecteurs/legifrance.py new file mode 100644 index 0000000..35724e5 --- /dev/null +++ b/pipeline/collecteurs/legifrance.py @@ -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) diff --git a/pipeline/collecteurs/senat.py b/pipeline/collecteurs/senat.py new file mode 100644 index 0000000..134dd63 --- /dev/null +++ b/pipeline/collecteurs/senat.py @@ -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 diff --git a/pipeline/rapprochement.py b/pipeline/rapprochement.py new file mode 100644 index 0000000..aa07728 --- /dev/null +++ b/pipeline/rapprochement.py @@ -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" + ) diff --git a/pipeline/update.py b/pipeline/update.py new file mode 100644 index 0000000..7ea4c2d --- /dev/null +++ b/pipeline/update.py @@ -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() diff --git a/tests/fixtures/cc_affaires_dc.html b/tests/fixtures/cc_affaires_dc.html new file mode 100644 index 0000000..29730db --- /dev/null +++ b/tests/fixtures/cc_affaires_dc.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/cc_decisions.html b/tests/fixtures/cc_decisions.html new file mode 100644 index 0000000..704b9d4 --- /dev/null +++ b/tests/fixtures/cc_decisions.html @@ -0,0 +1,345 @@ +
+ +
+ +
+ + +
+ + + + + + + +
+ +
+
+

Les décisions du Conseil Constitutionnel

+ +

Toutes les décisions, ainsi que des outils statistiques et explicatifs pour mieux connaître l’activité du Conseil constitutionnel depuis sa création

+
+ + + + +
+

Rechercher une décision

+ + +
+ +
+
+
+ + + +
+
+ + + +
+
+
+ + +Plus de critères de recherche + + +
+
+ +
+ +
+

Les dernières décisions

+ + + + + + + + + + + + + + + + + + + + +
+Toutes les décisions +
+ + +
+

Décision n° 2026-908 DC du 23 juillet 2026

+ +
+

Loi organique relative au renforcement des juridictions criminelles

+
+ +
+

Conformité

+
+ +
+
+
+ + +
+

Décision n° 2026-909 DC du 23 juillet 2026

+ +
+

Loi sur la justice criminelle et le respect des victimes

+
+ +
+

Non conformité partielle - réserve

+
+ +
+
+
+ + +
+

Décision n° 2026-906 DC du 23 juillet 2026

+ +
+

Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques d’attentat

+
+ +
+

Conformité - réserve

+
+ +
+
+
\ No newline at end of file diff --git a/tests/fixtures/senat_lois_promulguees.html b/tests/fixtures/senat_lois_promulguees.html new file mode 100644 index 0000000..406f39a --- /dev/null +++ b/tests/fixtures/senat_lois_promulguees.html @@ -0,0 +1,32 @@ +Loi n° 2026-650 du 23 juillet 2026 relative au renforcement des juridictions criminelles + parue au JO n°171 du 24 juillet 2026Loi n° 2026-651 du 23 juillet 2026 sur la justice criminelle et le respect des victimes + parue au JO n°171 du 24 juillet 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-534 du 25 juin 2026 relative à la lutte contre les fraudes sociales et fiscales + parue au JO n°148 du 26 juin 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-441 du 4 juin 2026 portant pérennisation du contrat de professionnalisation expérimental + parue au JO n°130 du 5 juin 2026Loi 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 2026Loi n° 2026-403 du 26 mai 2026 de simplification de la vie économique + parue au JO n°122 du 27 mai 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-103 du 19 février 2026 de finances pour 2026 + parue au JO n°43 du 20 février 2026Loi 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 \ No newline at end of file diff --git a/tests/test_acceptation.py b/tests/test_acceptation.py index f5cd6d4..0eca545 100644 --- a/tests/test_acceptation.py +++ b/tests/test_acceptation.py @@ -212,7 +212,10 @@ def test_une_fiche_a_timeline_et_sources(base_complete) -> None: def test_les_deductions_sont_signalees(base_complete) -> None: """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" diff --git a/tests/test_collecteurs.py b/tests/test_collecteurs.py new file mode 100644 index 0000000..c909da1 --- /dev/null +++ b/tests/test_collecteurs.py @@ -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 diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..72712c9 --- /dev/null +++ b/tests/test_update.py @@ -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