Files
Cyber MawonajandClaude Opus 5 29424afdd5 Intégration des 7 textes détectés par le pipeline — la veille converge
make update écrivait jusqu'ici les changements de statut et de date, mais se
contentait de journaliser les ajouts et les décisions du Conseil : les mêmes
écarts seraient revenus à chaque passage, indéfiniment. Un rapport qui répète
les mêmes lignes cesse d'être lu.

Deux manques comblés :
- un écart d'ajout porte désormais la collecte qui l'a produit, et appliquer()
  crée réellement le texte via classer_nouveau()
- un écart de décision porte l'affaire collectée, et l'écrit dans decisions_cc

Une affaire orpheline est rattachée par similarité de titre : le registre du
Conseil donne l'intitulé complet (« Loi actualisant la programmation militaire
pour les années 2024 à 2030… »), la base le nom d'usage. L'affaire 2026-907 DC
a ainsi trouvé sa loi toute seule.

Résultat sur les sources réelles :
  passage 1 → 7 ajouts, 4 modifications
  passage 2 → 0 ajout,  4 modifications (décisions désormais écrites)
  passage 3 → 0 écart : « la base est conforme aux sources officielles »

Les 7 textes ajoutés sont des lois de janvier à avril 2026, hors de la fenêtre
du corpus mais relevées par la liste du Sénat qui couvre toute la session. Dont
la loi de finances pour 2026 (n° 2026-103), qui touche l'octroi de mer et étend
le versement mobilité aux DROM — donc pertinente pour la Guadeloupe. Toutes
entrent avec confiance = low et le drapeau de revue : le pipeline constate
qu'un texte existe, il ne prétend pas l'avoir analysé.

Base : 59 textes, 367 sources, 13 affaires CC, 0 texte sans lien.
176 tests, make verifier au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:41:18 -04:00

550 lines
21 KiB
Python

"""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 = ""
# Renseignée pour les ajouts : c'est elle qui permet de créer le texte au
# moment d'appliquer, sans relancer une collecte.
collecte: TexteCollecte | None = None
# Idem pour les affaires du Conseil constitutionnel. Sans elle, un écart de
# décision serait journalisé mais jamais écrit — et redétecté à chaque
# passage, indéfiniment.
decision: DecisionCC | None = None
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, candidats
)
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,
collecte=collecte,
)
)
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,
candidats: list[tuple[str, str, str | None]] | None = None,
) -> None:
existante = connues.get(decision.numero_affaire)
if existante is None:
# Le registre du Conseil donne l'intitulé complet de la loi déférée :
# on tente de la rattacher à un texte suivi plutôt que de laisser
# l'affaire orpheline.
rattachement = None
if candidats and decision.resume:
correspondance = rapprocher(
titre=decision.resume, numero_officiel=None, candidats=candidats
)
if correspondance.issue is Issue.RAPPROCHE:
rattachement = correspondance.texte_id
rapport.ecarts.append(
Ecart(
texte_id=rattachement,
nature="decision_cc",
description=(
f"affaire inconnue en base : {decision.numero_affaire} — "
f"{(decision.resume or '')[:90]}"
+ (f" → rattachée à {rattachement}" if rattachement else "")
),
collecteur=collecteur,
decision=decision,
)
)
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,
decision=decision,
)
)
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,
decision=decision,
)
)
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,
decision=decision,
)
)
# ─────────────────────────────────────────────────────────────────────────────
# Application
# ─────────────────────────────────────────────────────────────────────────────
def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> int:
"""Écrit les écarts en base. Jamais appelé en mode `--dry-run`.
Retourne le nombre de textes réellement créés. Les textes ajoutés par cette
voie entrent avec la confiance la plus basse et le drapeau de revue : la
machine constate qu'un texte existe, elle ne prétend pas l'avoir analysé.
"""
crees = 0
with db.transaction(cx):
for ecart in rapport.ecarts:
if ecart.nature == "ajout" and ecart.collecte is not None:
texte = classer_nouveau(ecart.collecte)
db.enregistrer_texte(cx, texte)
ecart.texte_id = texte.id
crees += 1
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.nature == "decision_cc" and ecart.decision is not None:
db.enregistrer_decision_cc(cx, ecart.decision, texte_id=ecart.texte_id)
continue
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"
)
return crees
_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:
crees = appliquer(cx, rapport, run_id)
exportes = exporter_json(cx)
log.info(
"écarts appliqués",
textes_crees=crees,
dump=str(chemins.DUMP_JSON),
textes_exportes=exportes,
)
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()