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