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
+497
View File
@@ -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()