Phase 2 : parseur de l'annexe sec10 — 52 textes, 12 affaires CC
Briques réutilisables : - dates_fr : lit les formes réelles du corpus (« 23 avr. 2026 (JO 24 avr.) », « 1er juill. », « 24.07.2026 ») et distingue une date exacte d'une échéance prévisionnelle (« Fin août 2026 » → situé au 25, libellé conservé, jamais présenté comme un fait) - markdown : découpage des tableaux (barres échappées, lignes courtes), nettoyage, extraction des marqueurs [^dim07-24^] / [^phase5-3.1^] Parseur sec10 : - 27 lois promulguées + 21 lignes simples + 2 lignes éclatées en 4 textes = 52 - éclatement justifié : les lignes « accords Kazakhstan et Colombie » et « MACF » décrivent chacune deux textes de statuts différents - migration 002 : regime_libertes stocké à part, faute de savoir quel public la cotation globale du rapport vise — dérouler « droits+ » sur les trois publics aurait inventé une ventilation absente de la source - migration 003 : la facette « devant le CC » couvre aussi les saisines sans numéro d'affaire (programmation militaire), sinon 6 textes au lieu de 7 Trois pièges du corpus, corrigés et couverts par des tests : - « eau » matchait comme sous-chaîne dans réseaux / drapeau / Retailleau - une décision citée sans saisine créait une fausse saisine (loi Philippine) - la date de saisine était pêchée dans la colonne « échéance », datant la saisine de l'aide à mourir du mois suivant 72 tests, 90 % de couverture sur pipeline/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
31d3935bce
commit
c36b8dc631
@@ -0,0 +1,305 @@
|
||||
"""Parseur de l'annexe récapitulative.
|
||||
|
||||
Ces tests portent sur le corpus réel : ils échouent si le format d'entrée
|
||||
change, ce qui est le comportement voulu — un seed silencieusement incomplet
|
||||
serait pire qu'une erreur bruyante.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from pipeline.modeles import (
|
||||
Pertinence,
|
||||
RegimeLibertes,
|
||||
ResultatCC,
|
||||
Statut,
|
||||
Theme,
|
||||
TypeTexte,
|
||||
)
|
||||
from pipeline.parseurs.tableaux_sec10 import (
|
||||
_deduire_themes,
|
||||
_lire_pertinence,
|
||||
_lire_regime,
|
||||
_lire_statut,
|
||||
extraire_marqueurs_annexe,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Volumétrie
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_aucun_avertissement(textes_annexe) -> None:
|
||||
assert textes_annexe.avertissements == []
|
||||
|
||||
|
||||
def test_volumetrie_minimale(textes_annexe) -> None:
|
||||
# 27 lois promulguées + 23 lignes du tableau B, dont 2 éclatées en deux
|
||||
# textes chacune : 27 + 21 + 4 = 52.
|
||||
assert len(textes_annexe) == 52
|
||||
assert len(textes_annexe) >= 49, "le cahier des charges exige au moins 49 textes"
|
||||
|
||||
|
||||
def test_identifiants_uniques(textes_annexe) -> None:
|
||||
identifiants = [t.id for t in textes_annexe.textes]
|
||||
assert len(identifiants) == len(set(identifiants))
|
||||
|
||||
|
||||
def test_toutes_les_lois_promulguees_ont_numero_et_date(textes_annexe) -> None:
|
||||
promulguees = [t for t in textes_annexe.textes if t.statut is Statut.PROMULGUEE]
|
||||
assert len(promulguees) == 27
|
||||
for texte in promulguees:
|
||||
assert texte.numero_officiel, texte.id
|
||||
assert texte.date_promulgation, texte.id
|
||||
assert texte.regime_libertes is not None, texte.id
|
||||
assert texte.guadeloupe_pertinence is not None, texte.id
|
||||
|
||||
|
||||
def test_aucun_texte_non_promulgue_ne_porte_de_numero(textes_annexe) -> None:
|
||||
for texte in textes_annexe.textes:
|
||||
if texte.statut is not Statut.PROMULGUEE:
|
||||
assert texte.numero_officiel is None, texte.id
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Contrôles d'acceptation du §7 du cahier des charges
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_loi_chlordecone(par_identifiant) -> None:
|
||||
texte = par_identifiant["loi-2026-491"]
|
||||
assert texte.numero_officiel == "2026-491"
|
||||
assert texte.statut is Statut.PROMULGUEE
|
||||
assert texte.date_promulgation == date(2026, 6, 12)
|
||||
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
||||
assert {Theme.SANTE, Theme.OUTRE_MER, Theme.MEMOIRE_PATRIMOINE} <= set(texte.themes)
|
||||
|
||||
|
||||
def test_loi_fraudes_et_sa_decision_cc(par_identifiant) -> None:
|
||||
texte = par_identifiant["loi-2026-534"]
|
||||
assert texte.statut is Statut.PROMULGUEE
|
||||
assert texte.date_promulgation == date(2026, 6, 25)
|
||||
assert [d.numero_affaire for d in texte.decisions_cc] == ["2026-904 DC"]
|
||||
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
||||
|
||||
|
||||
def test_loi_riposte_statut_et_evenement_de_saisine(par_identifiant) -> None:
|
||||
"""Le §7 impose le statut `adoptee_non_promulguee` ET l'événement de saisine."""
|
||||
texte = par_identifiant["loi-riposte"]
|
||||
assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE
|
||||
assert texte.numero_officiel is None
|
||||
|
||||
saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"]
|
||||
assert len(saisines) == 1
|
||||
assert "2026-915 DC" in saisines[0].description
|
||||
assert "24 juillet 2026" in saisines[0].description
|
||||
assert saisines[0].date_evenement == date(2026, 7, 24)
|
||||
|
||||
(affaire,) = texte.decisions_cc
|
||||
assert affaire.numero_affaire == "2026-915 DC"
|
||||
assert affaire.resultat is ResultatCC.EN_INSTANCE
|
||||
assert affaire.date_saisine == date(2026, 7, 24)
|
||||
|
||||
|
||||
def test_ppl_aide_a_mourir(par_identifiant) -> None:
|
||||
texte = par_identifiant["ppl-aide-a-mourir"]
|
||||
assert texte.type is TypeTexte.PPL
|
||||
assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE
|
||||
assert texte.date_adoption == date(2026, 7, 15)
|
||||
(affaire,) = texte.decisions_cc
|
||||
assert affaire.numero_affaire == "2026-910 DC"
|
||||
# Le corpus ne date pas cette saisine : rien ne doit être inventé.
|
||||
assert affaire.date_saisine is None
|
||||
assert affaire.date_decision_attendue == date(2026, 8, 20)
|
||||
|
||||
|
||||
def test_pjl_pacte_migration(par_identifiant) -> None:
|
||||
texte = par_identifiant["pjl-pacte-migration"]
|
||||
assert texte.type is TypeTexte.PJL
|
||||
assert texte.statut is Statut.NAVETTE
|
||||
assert Theme.MIGRATION in texte.themes
|
||||
|
||||
|
||||
def test_loi_simplification_economique(par_identifiant) -> None:
|
||||
texte = par_identifiant["loi-2026-403"]
|
||||
assert texte.statut is Statut.PROMULGUEE
|
||||
(affaire,) = texte.decisions_cc
|
||||
assert affaire.numero_affaire == "2026-903 DC"
|
||||
assert affaire.resultat is ResultatCC.NON_CONFORMITE_PARTIELLE
|
||||
assert "25 articles censurés" in (affaire.resume or "")
|
||||
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
||||
|
||||
|
||||
def test_loi_philippine_est_validee_sans_fausse_saisine(par_identifiant) -> None:
|
||||
"""Sa cellule cite une décision, pas une saisine : aucun événement de saisine."""
|
||||
texte = par_identifiant["loi-philippine"]
|
||||
assert texte.statut is Statut.VALIDEE_CC
|
||||
assert [e.type_etape for e in texte.evenements if e.type_etape == "saisine_cc"] == []
|
||||
decisions = [e for e in texte.evenements if e.type_etape == "decision_cc"]
|
||||
assert decisions and decisions[0].date_evenement == date(2026, 7, 23)
|
||||
|
||||
|
||||
def test_programmation_militaire_saisie_sans_numero_d_affaire(par_identifiant) -> None:
|
||||
"""« Conseil constitutionnel saisi le 6 juill. » : événement, pas d'affaire."""
|
||||
texte = par_identifiant["loi-programmation-militaire"]
|
||||
saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"]
|
||||
assert len(saisines) == 1
|
||||
assert saisines[0].date_evenement == date(2026, 7, 6)
|
||||
assert texte.decisions_cc == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Éclatement des lignes doubles
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_accords_bilateraux_eclates(par_identifiant) -> None:
|
||||
kazakhstan = par_identifiant["accord-kazakhstan-readmission"]
|
||||
colombie = par_identifiant["accord-colombie-extradition"]
|
||||
assert kazakhstan.type is TypeTexte.ACCORD_INTERNATIONAL
|
||||
assert kazakhstan.date_adoption == date(2026, 1, 28)
|
||||
assert colombie.date_adoption == date(2026, 2, 18)
|
||||
|
||||
|
||||
def test_macf_eclate_en_deux_statuts(par_identifiant) -> None:
|
||||
"""La ligne MACF décrit deux textes de statuts différents."""
|
||||
ratification = par_identifiant["pjl-ratification-ordonnance-macf"]
|
||||
accord = par_identifiant["accord-macf-plateforme-centrale"]
|
||||
assert ratification.statut is Statut.DEPOSEE_NON_EXAMINEE
|
||||
assert accord.statut is Statut.ANNONCEE
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Lecture de cellules
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cellule", "attendu"),
|
||||
[
|
||||
("**Droits+** : contrôle externe de la détention", RegimeLibertes.DROITS_PLUS),
|
||||
("**Contrôle+** : durées réduites", RegimeLibertes.CONTROLE_PLUS),
|
||||
("Mixte : anti-impayés ; vigilance débiteur", RegimeLibertes.MIXTE),
|
||||
("Neutre", RegimeLibertes.NEUTRE),
|
||||
("Neutre à **droits+** : équité territoriale", RegimeLibertes.DROITS_PLUS),
|
||||
],
|
||||
)
|
||||
def test_lire_regime(cellule: str, attendu: RegimeLibertes) -> None:
|
||||
regime, note = _lire_regime(cellule)
|
||||
assert regime is attendu
|
||||
assert note
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cellule", "attendu", "note"),
|
||||
[
|
||||
("Forte — maison d'arrêt de Baie-Mahault", Pertinence.FORTE, "maison d'arrêt"),
|
||||
("Moyenne — CPCE applicable aux DROM", Pertinence.MOYENNE, "CPCE"),
|
||||
("Faible — sans objet", Pertinence.FAIBLE, "sans objet"),
|
||||
],
|
||||
)
|
||||
def test_lire_pertinence(cellule: str, attendu: Pertinence, note: str) -> None:
|
||||
pertinence, motif = _lire_pertinence(cellule)
|
||||
assert pertinence is attendu
|
||||
assert note in (motif or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cellule", "attendu"),
|
||||
[
|
||||
("**Adoptée, non promulguée** (21 juill., CMP)", Statut.ADOPTEE_NON_PROMULGUEE),
|
||||
("**En navette** : Sénat 1re lecture 20 mai", Statut.NAVETTE),
|
||||
("**Déposé, non examiné** : Sénat n° 890", Statut.DEPOSEE_NON_EXAMINEE),
|
||||
("**Annoncé** : transmis au Conseil d'État", Statut.ANNONCEE),
|
||||
("**Adoptée par une chambre** : Sénat 5-6 mai", Statut.NAVETTE),
|
||||
("**Adoptée en 1re lecture** : AN 28 mai", Statut.NAVETTE),
|
||||
("**Adoptée** (15-16 juin) ; conforme avec réserves ; **promulgation pendante**",
|
||||
Statut.VALIDEE_CC),
|
||||
],
|
||||
)
|
||||
def test_lire_statut(cellule: str, attendu: Statut) -> None:
|
||||
assert _lire_statut(cellule) is attendu
|
||||
|
||||
|
||||
def test_lire_statut_refuse_l_inconnu() -> None:
|
||||
with pytest.raises(ValueError, match="statut non reconnu"):
|
||||
_lire_statut("Texte au statut fantaisiste")
|
||||
|
||||
|
||||
def test_themes_ne_confondent_pas_les_sous_chaines() -> None:
|
||||
"""« eau » ne doit pas se déclencher sur « réseaux », « drapeau », « Retailleau »."""
|
||||
assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL réseaux sociaux et mineurs")
|
||||
assert Theme.ENVIRONNEMENT not in _deduire_themes("outrage à l'hymne et au drapeau")
|
||||
assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL entrisme Retailleau")
|
||||
# Contrôle positif : le mot isolé compte bien.
|
||||
assert Theme.ENVIRONNEMENT in _deduire_themes("habilitation Martinique énergie et eau")
|
||||
|
||||
|
||||
def test_themes_plafonnes_a_quatre() -> None:
|
||||
long_texte = " ".join(
|
||||
["justice", "sécurité", "numérique", "social", "fiscal", "agriculture", "santé"]
|
||||
)
|
||||
assert len(_deduire_themes(long_texte)) <= 4
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Marqueurs de citation
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_marqueurs_de_l_annexe(annexe: str) -> None:
|
||||
associations = extraire_marqueurs_annexe(annexe)
|
||||
assert "2026-491" in associations
|
||||
assert "dim07-24" in associations["2026-491"]
|
||||
riposte = next(cle for cle in associations if cle.startswith("Loi « Riposte »"))
|
||||
assert "phase5-3.1" in associations[riposte]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Écriture en base
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_seed_en_base(base_ensemencee) -> None:
|
||||
cx = base_ensemencee
|
||||
assert cx.execute("SELECT COUNT(*) FROM textes").fetchone()[0] == 52
|
||||
assert cx.execute("SELECT COUNT(*) FROM decisions_cc").fetchone()[0] == 12
|
||||
|
||||
|
||||
def test_facette_devant_cc(base_ensemencee) -> None:
|
||||
"""Le §8.7 attend au moins 7 textes suspendus au Conseil constitutionnel."""
|
||||
total = base_ensemencee.execute(
|
||||
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1"
|
||||
).fetchone()[0]
|
||||
assert total >= 7
|
||||
|
||||
|
||||
def test_les_lois_promulguees_ne_sont_pas_devant_le_cc(base_ensemencee) -> None:
|
||||
"""Une décision rendue mais non datée ne doit pas passer pour une instance."""
|
||||
restantes = base_ensemencee.execute(
|
||||
"SELECT id FROM v_textes WHERE devant_cc = 1 AND statut = 'promulguee'"
|
||||
).fetchall()
|
||||
assert restantes == []
|
||||
|
||||
|
||||
def test_recherche_plein_texte(base_ensemencee) -> None:
|
||||
"""La recherche doit trouver « chlordécone » sans les accents."""
|
||||
lignes = base_ensemencee.execute(
|
||||
"SELECT t.id FROM textes_fts f JOIN textes t ON t.rowid = f.rowid "
|
||||
"WHERE textes_fts MATCH 'chlordecone'"
|
||||
).fetchall()
|
||||
assert [ligne["id"] for ligne in lignes] == ["loi-2026-491"]
|
||||
|
||||
|
||||
def test_ecriture_idempotente(base, textes_annexe) -> None:
|
||||
"""Rejouer le seed ne duplique ni textes, ni sources, ni événements."""
|
||||
from pipeline import db
|
||||
|
||||
db.enregistrer_textes(base, textes_annexe.textes)
|
||||
premier = db.statistiques(base)
|
||||
db.enregistrer_textes(base, textes_annexe.textes)
|
||||
second = db.statistiques(base)
|
||||
assert premier == second
|
||||
Reference in New Issue
Block a user