Files
veye-lalwa/tests/test_update.py
T
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

481 lines
18 KiB
Python

"""Run de veille : détection des écarts, classification, export.
Aucun test ne va sur le réseau : les collectes sont fabriquées à la main, ce
qui permet de vérifier exactement quels écarts le pipeline doit voir — et
surtout lesquels il ne doit pas inventer.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import date
import pytest
from pipeline import db
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
from pipeline.modeles import (
Confiance,
DecisionCC,
Evenement,
Pertinence,
ResultatCC,
Source,
Statut,
Texte,
TypeEtape,
TypeTexte,
)
from pipeline.update import appliquer, classer_nouveau, comparer, exporter_json
@pytest.fixture
def base(tmp_path) -> sqlite3.Connection:
"""Base minimale : une loi promulguée et une loi en attente de décision."""
cx = db.initialiser(tmp_path / "veille.db")
db.enregistrer_textes(
cx,
[
Texte(
id="loi-2026-491",
numero_officiel="2026-491",
type=TypeTexte.LOI,
titre_court="Chlordécone : responsabilité de l'État",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 6, 12),
guadeloupe_pertinence=Pertinence.FORTE,
sources=[
Source(
url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
editeur="Légifrance",
)
],
evenements=[
Evenement(
date_evenement=date(2026, 6, 12),
type_etape=TypeEtape.PROMULGATION,
description="Promulgation",
)
],
),
Texte(
id="loi-riposte",
type=TypeTexte.LOI,
titre_court="Loi « Riposte » (ordre public, sécurité, tranquillité)",
statut=Statut.ADOPTEE_NON_PROMULGUEE,
date_adoption=date(2026, 7, 21),
sources=[Source(url="https://www.assemblee-nationale.fr/dyn/17/textes/l17t0340")],
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_saisine=date(2026, 7, 24),
resultat=ResultatCC.EN_INSTANCE,
)
],
),
],
)
return cx
def _collecte(**kwargs) -> ResultatCollecte:
return ResultatCollecte(collecteur="essai", **kwargs)
# ─────────────────────────────────────────────────────────────────────────────
# Détection des écarts
# ─────────────────────────────────────────────────────────────────────────────
def test_aucun_ecart_quand_la_source_confirme_la_base(base) -> None:
"""Le cas normal : la veille ne doit rien signaler quand rien ne bouge."""
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-491 du 12 juin 2026 chlordécone",
source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
collecteur="essai",
numero_officiel="2026-491",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 6, 12),
)
]
)
rapport = comparer(base, [collecte])
assert rapport.ecarts == []
def test_un_texte_inconnu_est_un_ajout(base) -> None:
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-700 du 1er septembre 2026 relative aux transports scolaires",
source_url="https://www.legifrance.gouv.fr/jorf/id/X",
collecteur="essai",
numero_officiel="2026-700",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 9, 1),
)
]
)
rapport = comparer(base, [collecte])
assert rapport.ajouts == 1
assert rapport.ecarts[0].nature == "ajout"
def test_un_changement_de_statut_est_detecte(base) -> None:
"""La promulgation de la loi « Riposte » : l'événement que la veille attend."""
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://www.legifrance.gouv.fr/jorf/id/Y",
collecteur="essai",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 8, 26),
)
]
)
rapport = comparer(base, [collecte])
statuts = [e for e in rapport.ecarts if e.nature == "statut"]
assert len(statuts) == 1
assert statuts[0].texte_id == "loi-riposte"
assert statuts[0].ancienne_valeur == "adoptee_non_promulguee"
assert statuts[0].nouvelle_valeur == "promulguee"
def test_une_decision_rendue_est_detectee(base) -> None:
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_decision=date(2026, 8, 24),
resultat=ResultatCC.NON_CONFORMITE_PARTIELLE,
)
]
)
rapport = comparer(base, [collecte])
natures = {e.champ for e in rapport.ecarts if e.nature == "decision_cc"}
assert any("date_decision" in (c or "") for c in natures)
assert any("resultat" in (c or "") for c in natures)
def test_une_affaire_inconnue_est_signalee(base) -> None:
"""Cas réel : le corpus ignorait le numéro 2026-907 DC de la LPM."""
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-907 DC",
date_saisine=date(2026, 7, 6),
resume="Loi actualisant la programmation militaire",
)
]
)
rapport = comparer(base, [collecte])
assert any("affaire inconnue" in e.description for e in rapport.ecarts)
def test_une_affaire_en_instance_deja_connue_ne_produit_rien(base) -> None:
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_saisine=date(2026, 7, 24),
resultat=ResultatCC.EN_INSTANCE,
)
]
)
assert comparer(base, [collecte]).ecarts == []
# ─────────────────────────────────────────────────────────────────────────────
# Écriture
# ─────────────────────────────────────────────────────────────────────────────
def test_le_dry_run_n_ecrit_rien(base) -> None:
"""`comparer` ne doit jamais toucher à la base."""
avant = db.statistiques(base)
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://x.fr/y",
collecteur="essai",
statut=Statut.PROMULGUEE,
)
]
)
comparer(base, [collecte])
assert db.statistiques(base) == avant
def test_appliquer_ecrit_le_changement_et_le_journalise(base) -> None:
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://x.fr/y",
collecteur="essai",
statut=Statut.PROMULGUEE,
)
]
)
rapport = comparer(base, [collecte])
run_id = db.ouvrir_run(base, "update")
appliquer(base, rapport, run_id)
statut = base.execute("SELECT statut FROM textes WHERE id = 'loi-riposte'").fetchone()[0]
assert statut == "promulguee"
journal = base.execute(
"SELECT nature, champ, nouvelle_valeur FROM veille_changements WHERE run_id = ?",
(run_id,),
).fetchall()
assert any(
ligne["champ"] == "statut" and ligne["nouvelle_valeur"] == "promulguee"
for ligne in journal
)
# ─────────────────────────────────────────────────────────────────────────────
# Classification d'un texte nouveau
# ─────────────────────────────────────────────────────────────────────────────
def test_un_texte_collecte_est_marque_comme_automatique() -> None:
"""§5.3 : la machine propose avec une confiance basse, l'humain valide."""
texte = classer_nouveau(
TexteCollecte(
titre="LOI n° 2026-700 du 1er septembre 2026 relative à l'eau en Guadeloupe",
source_url="https://www.legifrance.gouv.fr/jorf/id/Z",
collecteur="legifrance",
numero_officiel="2026-700",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 9, 1),
)
)
assert texte.id == "loi-2026-700"
assert texte.confiance is Confiance.LOW
assert texte.a_verifier is True
assert "déduits" in (texte.motif_verification or "")
assert texte.guadeloupe_pertinence is Pertinence.FORTE # « Guadeloupe » dans l'intitulé
def test_un_texte_sans_numero_recoit_un_identifiant_lisible() -> None:
texte = classer_nouveau(
TexteCollecte(
titre="Proposition de loi relative aux sargasses en Martinique",
source_url="https://www.senat.fr/x",
collecteur="senat",
statut=Statut.NAVETTE,
)
)
assert texte.id and " " not in texte.id
assert "sargasses" in texte.id
# ─────────────────────────────────────────────────────────────────────────────
# Export statique
# ─────────────────────────────────────────────────────────────────────────────
def test_export_json(base, tmp_path, monkeypatch) -> None:
from pipeline import chemins
destination = tmp_path / "textes.json"
monkeypatch.setattr(chemins, "DUMP_JSON", destination)
total = exporter_json(base)
assert total == 2
charge = json.loads(destination.read_text(encoding="utf-8"))
assert charge["date_arrete_corpus"] == "2026-07-25"
identifiants = {t["id"] for t in charge["textes"]}
assert identifiants == {"loi-2026-491", "loi-riposte"}
chlordecone = next(t for t in charge["textes"] if t["id"] == "loi-2026-491")
assert chlordecone["sources"][0]["url"].startswith("https://www.legifrance.gouv.fr")
assert chlordecone["evenements"][0]["type_etape"] == "promulgation"
riposte = next(t for t in charge["textes"] if t["id"] == "loi-riposte")
assert riposte["decisions_cc"][0]["numero_affaire"] == "2026-915 DC"
def test_rapport_lisible(base) -> None:
rapport = comparer(base, [_collecte()])
rapport.mode = "dry-run"
texte = rapport.en_texte()
assert "Rapport de veille" in texte
assert "conforme aux sources officielles" in texte
# ─────────────────────────────────────────────────────────────────────────────
# Cotation Guadeloupe : les négations
# ─────────────────────────────────────────────────────────────────────────────
def test_une_mention_niee_ne_vaut_pas_pertinence() -> None:
"""« Sans portée pour la Guadeloupe » nomme le territoire pour l'écarter.
Sans lecture de la négation, la PPL montagne recevait la cotation la plus
forte alors que le rapport dit l'inverse — et remontait dans la facette
« pertinence forte pour la Guadeloupe ».
"""
from pipeline.guadeloupe import coter
cotation = coter(
"PPL « pour une montagne vivante et souveraine ». Continuité de "
"l'urbanisation ; sans portée pour la Guadeloupe."
)
assert cotation.pertinence is Pertinence.FAIBLE
assert cotation.score == 0
assert cotation.exclusions
assert "écarte explicitement" in (cotation.note or "")
def test_pas_de_volet_outre_mer_est_une_negation() -> None:
from pipeline.guadeloupe import coter
cotation = coter("PJL logement. Véto des maires ; pas de volet outre-mer à ce stade.")
assert cotation.pertinence is Pertinence.FAIBLE
assert cotation.score == 0
def test_une_mention_affirmee_compte_toujours() -> None:
from pipeline.guadeloupe import coter
cotation = coter(
"Chlordécone : responsabilité de l'État. Guadeloupe et Martinique au cœur du texte."
)
assert cotation.pertinence is Pertinence.FORTE
assert "guadeloupe" in cotation.expressions
assert not cotation.exclusions
def test_la_negation_ne_porte_que_sur_son_voisinage() -> None:
"""Une exclusion lointaine ne doit pas annuler une mention affirmée."""
from pipeline.guadeloupe import coter
cotation = coter(
"Fouilles jusqu'à 40 km du littoral, soit la quasi-totalité de la Guadeloupe. "
+ "Texte de portée générale. " * 12
+ "Le dispositif est sans objet pour les collectivités du Pacifique."
)
assert cotation.pertinence is Pertinence.FORTE
# ─────────────────────────────────────────────────────────────────────────────
# Application effective : créer, mettre à jour, converger
# ─────────────────────────────────────────────────────────────────────────────
def test_un_ajout_cree_reellement_le_texte(base) -> None:
"""Un écart d'ajout doit écrire le texte, pas seulement le journaliser."""
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-103 du 19 février 2026 de finances pour 2026",
source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999",
collecteur="senat",
numero_officiel="2026-103",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 2, 19),
sources=[
Source(
url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999",
editeur="Légifrance",
)
],
)
]
)
rapport = comparer(base, [collecte])
crees = appliquer(base, rapport, db.ouvrir_run(base, "update"))
assert crees == 1
ligne = base.execute("SELECT * FROM textes WHERE id = 'loi-2026-103'").fetchone()
assert ligne is not None
assert ligne["confiance"] == "low"
assert ligne["a_verifier"] == 1
assert ligne["source_seed"] == "collecteur:senat"
# Un texte sans source serait un texte invérifiable : la collecte doit
# transmettre la sienne.
assert base.execute(
"SELECT COUNT(*) FROM sources WHERE texte_id = 'loi-2026-103'"
).fetchone()[0] >= 1
def test_une_decision_collectee_est_ecrite_pas_seulement_journalisee(base) -> None:
"""Sans écriture, le même écart serait redétecté à chaque passage."""
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_decision=date(2026, 8, 24),
resultat=ResultatCC.NON_CONFORMITE_PARTIELLE,
)
]
)
rapport = comparer(base, [collecte])
appliquer(base, rapport, db.ouvrir_run(base, "update"))
ligne = base.execute(
"SELECT * FROM decisions_cc WHERE numero_affaire = '2026-915 DC'"
).fetchone()
assert ligne["date_decision"] == "2026-08-24"
assert ligne["resultat"] == "non_conformite_partielle"
def test_le_pipeline_converge(base) -> None:
"""Rejouer la même collecte après application ne doit plus rien signaler.
C'est la propriété qui rend une veille utilisable : si le rapport répétait
les mêmes écarts à chaque passage, il deviendrait illisible et on cesserait
de le lire.
"""
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-103 du 19 février 2026 de finances pour 2026",
source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999",
collecteur="senat",
numero_officiel="2026-103",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 2, 19),
)
],
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_decision=date(2026, 8, 24),
resultat=ResultatCC.NON_CONFORMITE_PARTIELLE,
)
],
)
premier = comparer(base, [collecte])
assert premier.ecarts, "le premier passage doit voir des écarts"
appliquer(base, premier, db.ouvrir_run(base, "update"))
second = comparer(base, [collecte])
assert second.ecarts == [], "le second passage ne doit plus rien signaler"
def test_une_affaire_orpheline_est_rattachee_par_son_intitule(base) -> None:
"""Cas réel : le Conseil donne l'intitulé complet, la base le nom d'usage."""
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-916 DC",
date_saisine=date(2026, 8, 1),
resume="Loi « Riposte » ordre public sécurité tranquillité",
)
]
)
rapport = comparer(base, [collecte])
(ecart,) = [e for e in rapport.ecarts if e.nature == "decision_cc"]
assert ecart.texte_id == "loi-riposte"
assert "rattachée à loi-riposte" in ecart.description