2026-07-25 21:48:56 -04:00
|
|
|
"""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 = ""
|
2026-07-25 22:41:18 -04:00
|
|
|
# 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
|
2026-07-25 21:48:56 -04:00
|
|
|
|
|
|
|
|
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:
|
2026-07-25 22:41:18 -04:00
|
|
|
_comparer_decision(
|
|
|
|
|
decision, affaires_connues, rapport, collecte.collecteur, candidats
|
|
|
|
|
)
|
2026-07-25 21:48:56 -04:00
|
|
|
|
|
|
|
|
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,
|
2026-07-25 22:41:18 -04:00
|
|
|
collecte=collecte,
|
2026-07-25 21:48:56 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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,
|
2026-07-25 22:41:18 -04:00
|
|
|
candidats: list[tuple[str, str, str | None]] | None = None,
|
2026-07-25 21:48:56 -04:00
|
|
|
) -> None:
|
|
|
|
|
existante = connues.get(decision.numero_affaire)
|
|
|
|
|
|
|
|
|
|
if existante is None:
|
2026-07-25 22:41:18 -04:00
|
|
|
# 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
|
|
|
|
|
|
2026-07-25 21:48:56 -04:00
|
|
|
rapport.ecarts.append(
|
|
|
|
|
Ecart(
|
2026-07-25 22:41:18 -04:00
|
|
|
texte_id=rattachement,
|
2026-07-25 21:48:56 -04:00
|
|
|
nature="decision_cc",
|
|
|
|
|
description=(
|
|
|
|
|
f"affaire inconnue en base : {decision.numero_affaire} — "
|
|
|
|
|
f"{(decision.resume or '')[:90]}"
|
2026-07-25 22:41:18 -04:00
|
|
|
+ (f" → rattachée à {rattachement}" if rattachement else "")
|
2026-07-25 21:48:56 -04:00
|
|
|
),
|
|
|
|
|
collecteur=collecteur,
|
2026-07-25 22:41:18 -04:00
|
|
|
decision=decision,
|
2026-07-25 21:48:56 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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,
|
2026-07-25 22:41:18 -04:00
|
|
|
decision=decision,
|
2026-07-25 21:48:56 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-25 22:41:18 -04:00
|
|
|
decision=decision,
|
2026-07-25 21:48:56 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-25 22:41:18 -04:00
|
|
|
decision=decision,
|
2026-07-25 21:48:56 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# Application
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
2026-07-25 22:41:18 -04:00
|
|
|
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
|
|
|
|
|
|
2026-07-25 21:48:56 -04:00
|
|
|
with db.transaction(cx):
|
2026-07-25 22:41:18 -04:00
|
|
|
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
|
|
|
|
|
|
2026-07-25 21:48:56 -04:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 22:41:18 -04:00
|
|
|
if ecart.nature == "decision_cc" and ecart.decision is not None:
|
|
|
|
|
db.enregistrer_decision_cc(cx, ecart.decision, texte_id=ecart.texte_id)
|
|
|
|
|
continue
|
|
|
|
|
|
2026-07-25 21:48:56 -04:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 22:41:18 -04:00
|
|
|
return crees
|
|
|
|
|
|
2026-07-25 21:48:56 -04:00
|
|
|
|
|
|
|
|
_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:
|
2026-07-25 22:41:18 -04:00
|
|
|
crees = appliquer(cx, rapport, run_id)
|
2026-07-25 21:48:56 -04:00
|
|
|
exportes = exporter_json(cx)
|
2026-07-25 22:41:18 -04:00
|
|
|
log.info(
|
|
|
|
|
"écarts appliqués",
|
|
|
|
|
textes_crees=crees,
|
|
|
|
|
dump=str(chemins.DUMP_JSON),
|
|
|
|
|
textes_exportes=exportes,
|
|
|
|
|
)
|
2026-07-25 21:48:56 -04:00
|
|
|
|
|
|
|
|
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()
|