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
@@ -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
|
||||
Reference in New Issue
Block a user