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:
Cyber Mawonaj
2026-07-25 18:22:18 -04:00
co-authored by Claude Opus 5
parent 31d3935bce
commit c36b8dc631
12 changed files with 1747 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Fixtures partagées.
Le corpus de `data/input/` est lu tel quel : c'est la seule façon de garantir
que les parseurs suivent le format réel, et non une idée qu'on s'en fait. Les
tests ne l'écrivent jamais.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
import pytest
from pipeline import chemins, db
@pytest.fixture(scope="session")
def annexe() -> str:
"""Contenu de `sec10.md`, l'annexe récapitulative."""
return chemins.chapitre(10).read_text(encoding="utf-8")
@pytest.fixture(scope="session")
def textes_annexe():
"""Textes extraits de l'annexe, analysés une seule fois pour la session."""
from pipeline.parseurs.tableaux_sec10 import parser
return parser(chemins.chapitre(10).read_text(encoding="utf-8"))
@pytest.fixture(scope="session")
def par_identifiant(textes_annexe) -> dict:
return {texte.id: texte for texte in textes_annexe.textes}
@pytest.fixture
def base(tmp_path) -> Iterator[sqlite3.Connection]:
"""Base SQLite éphémère, migrée, propre à chaque test."""
cx = db.initialiser(tmp_path / "test.db")
try:
yield cx
finally:
cx.close()
@pytest.fixture
def base_ensemencee(base, textes_annexe) -> sqlite3.Connection:
"""Base contenant les textes de l'annexe."""
db.enregistrer_textes(base, textes_annexe.textes)
return base
+81
View File
@@ -0,0 +1,81 @@
"""Lecture des dates françaises du corpus."""
from __future__ import annotations
from datetime import date
import pytest
from pipeline.parseurs.dates_fr import lire_date, lire_echeance, sans_accents, toutes_les_dates
@pytest.mark.parametrize(
("fragment", "attendu"),
[
# Formes réelles de la colonne « Promulgation / JO » du tableau A.
("23 avr. 2026 (JO 24 avr.)", date(2026, 4, 23)),
("29 avr. 2026 (JO 30 avr.)", date(2026, 4, 29)),
("9 mai 2026 (JO 10 mai)", date(2026, 5, 9)),
("12 juin 2026 (JO 13 juin)", date(2026, 6, 12)),
("30 juin 2026 (JO 1er juill.)", date(2026, 6, 30)),
("13 juill. 2026 (JO 14 juill.)", date(2026, 7, 13)),
("1er juill. 2026", date(2026, 7, 1)),
# Formes numériques du fichier de validation de phase 5.
("24.07.2026", date(2026, 7, 24)),
("22/07/2026", date(2026, 7, 22)),
("2026-07-25", date(2026, 7, 25)),
],
)
def test_lire_date_formes_du_corpus(fragment: str, attendu: date) -> None:
assert lire_date(fragment) == attendu
def test_lire_date_sans_annee_utilise_le_defaut() -> None:
assert lire_date("6 juill.", annee_defaut=2026) == date(2026, 7, 6)
assert lire_date("6 juill.") is None
def test_lire_date_ignore_les_fragments_sans_jour() -> None:
assert lire_date("Fin août 2026") is None
assert lire_date("Automne 2026") is None
def test_toutes_les_dates_conserve_l_ordre() -> None:
assert toutes_les_dates("23 avr. 2026 (JO 24 avr.)", annee_defaut=2026) == [
date(2026, 4, 23),
date(2026, 4, 24),
]
@pytest.mark.parametrize(
("fragment", "attendu"),
[
("~24 août 2026", date(2026, 8, 24)),
("Début août 2026", date(2026, 8, 5)),
("Fin août 2026", date(2026, 8, 25)),
("Oct. 2026 ; adoption visée fin 2026", date(2026, 10, 15)),
("Automne 2026", date(2026, 10, 1)),
("Rentrée 2026", date(2026, 9, 1)),
],
)
def test_lire_echeance_situe_les_previsions(fragment: str, attendu: date) -> None:
approchee, libelle = lire_echeance(fragment)
assert approchee == attendu
# Le libellé d'origine est toujours conservé : c'est lui qui est affiché.
assert libelle == fragment
@pytest.mark.parametrize(
"fragment",
["Indéterminée", "Sans échéance", "Sans date fixée", "Session ordinaire 2026-2027"],
)
def test_lire_echeance_refuse_d_inventer(fragment: str) -> None:
approchee, libelle = lire_echeance(fragment)
assert approchee is None
assert libelle == fragment
def test_sans_accents() -> None:
assert sans_accents("août") == "aout"
assert sans_accents("Échéance probable") == "Echeance probable"
assert sans_accents("chlordécone") == "chlordecone"
+83
View File
@@ -0,0 +1,83 @@
"""Découpage des tableaux, nettoyage et marqueurs de citation."""
from __future__ import annotations
from pipeline.parseurs.markdown import (
decouper_sections,
extraire_marqueurs,
extraire_tableaux,
nettoyer,
)
TABLEAU = """\
## Tableau d'essai
| Col A | Col B |
|---|---|
| a1 | b1 |
| a2 | b2 |
Texte après le tableau.
"""
def test_extraire_tableaux_simple() -> None:
(tableau,) = extraire_tableaux(TABLEAU)
assert tableau.entetes == ["Col A", "Col B"]
assert tableau.lignes == [{"Col A": "a1", "Col B": "b1"}, {"Col A": "a2", "Col B": "b2"}]
assert tableau.section == "Tableau d'essai"
assert len(tableau) == 2
def test_extraire_tableaux_tolere_une_ligne_courte() -> None:
source = "| A | B | C |\n|---|---|---|\n| 1 | 2 |\n"
(tableau,) = extraire_tableaux(source)
assert tableau.lignes == [{"A": "1", "B": "2", "C": ""}]
def test_extraire_tableaux_respecte_les_barres_echappees() -> None:
source = "| A | B |\n|---|---|\n| x \\| y | z |\n"
(tableau,) = extraire_tableaux(source)
assert tableau.lignes[0]["A"] == "x | y"
def test_extraire_tableaux_sur_le_corpus_reel(annexe: str) -> None:
tableaux = extraire_tableaux(annexe)
assert len(tableaux) == 2
tableau_a, tableau_b = tableaux
assert tableau_a.entetes[0] == "N° de loi"
assert len(tableau_a) == 27, "27 lois promulguées attendues au tableau A"
assert tableau_b.entetes[0] == "Texte"
assert len(tableau_b) == 23
def test_nettoyer_retire_emphase_et_marqueurs() -> None:
brut = "**Adoptée, non promulguée** (21 juill.) ; saisine [^dim04-487^][^phase5-3.1^]"
assert nettoyer(brut) == "Adoptée, non promulguée (21 juill.) ; saisine"
def test_nettoyer_peut_garder_les_marqueurs() -> None:
brut = "Chlordécone [^dim07-24^]"
assert nettoyer(brut, garder_marqueurs=True) == "Chlordécone [^dim07-24^]"
def test_extraire_marqueurs_distingue_les_dimensions() -> None:
marqueurs = extraire_marqueurs("[^dim07-24^] et [^phase5-3.1^] et [^insight-5^] et [^42^]")
assert [(m.dimension, m.numero) for m in marqueurs] == [
("dim07", "24"),
("phase5", "3.1"),
("insight", "5"),
("", "42"),
]
def test_extraire_marqueurs_dedoublonne() -> None:
assert len(extraire_marqueurs("[^dim01-3^] [^dim01-3^] [^dim01-4^]")) == 2
def test_decouper_sections() -> None:
source = "# Titre\ncorps un\n\n## Sous\ncorps deux\n"
sections = decouper_sections(source)
assert [s.titre for s in sections] == ["Titre", "Sous"]
assert sections[0].corps == "corps un"
assert sections[1].niveau == 2
+305
View File
@@ -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