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