"""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()