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:
Cyber Mawonaj
2026-07-25 21:48:56 -04:00
co-authored by Claude Opus 5
parent c9fc8f3f67
commit feb5544599
15 changed files with 2743 additions and 1 deletions
+311
View File
@@ -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