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
+2 -1
View File
@@ -101,7 +101,8 @@ _CHAMPS_TEXTE = (
"id", "numero_officiel", "type", "titre_court", "titre_officiel", "id", "numero_officiel", "type", "titre_court", "titre_officiel",
"statut", "statut_date", "date_depot", "date_adoption", "date_promulgation", "statut", "statut_date", "date_depot", "date_adoption", "date_promulgation",
"date_entree_vigueur", "prochaine_echeance", "prochaine_echeance_label", "date_entree_vigueur", "prochaine_echeance", "prochaine_echeance_label",
"themes", "impacts", "guadeloupe_pertinence", "guadeloupe_note", "themes", "impacts", "regime_libertes", "regime_libertes_note",
"guadeloupe_pertinence", "guadeloupe_note",
"resume", "points_cles", "confiance", "source_seed", "resume", "points_cles", "confiance", "source_seed",
"a_verifier", "motif_verification", "a_verifier", "motif_verification",
) )
@@ -0,0 +1,32 @@
-- ============================================================================
-- Migration 002 — régime de libertés
--
-- L'annexe du rapport (`sec10.md`, note méthodologique) cote chaque texte selon
-- trois régimes : « droits+ » (création ou extension de droits), « contrôle+ »
-- (restriction, ou extension des pouvoirs de contrôle), « mixte » ou « neutre ».
--
-- Cette cotation porte sur le texte dans son ensemble : elle ne dit pas quel
-- public est touché. Elle est donc stockée à part, et non dépliée d'office dans
-- `impacts` — remplir les trois publics à partir d'une cotation globale
-- reviendrait à inventer une ventilation que la source ne donne pas.
-- ============================================================================
ALTER TABLE textes ADD COLUMN regime_libertes TEXT
CHECK (regime_libertes IN ('droits_plus', 'controle_plus', 'mixte', 'neutre'));
ALTER TABLE textes ADD COLUMN regime_libertes_note TEXT;
CREATE INDEX idx_textes_regime ON textes (regime_libertes);
DROP VIEW v_textes;
CREATE VIEW v_textes AS
SELECT
t.*,
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
CASE WHEN t.statut = 'saisie_cc'
OR EXISTS (SELECT 1 FROM decisions_cc d
WHERE d.texte_id = t.id AND d.date_decision IS NULL)
THEN 1 ELSE 0 END AS devant_cc
FROM textes t;
+43
View File
@@ -0,0 +1,43 @@
-- ============================================================================
-- Migration 003 — la facette « devant le Conseil constitutionnel »
--
-- Trois situations désignent un texte suspendu au Conseil constitutionnel, et
-- le corpus les documente inégalement :
--
-- 1. le statut vaut littéralement `saisie_cc` ;
-- 2. une affaire est enregistrée avec le résultat `en_instance` (« 2026-915
-- DC ») — c'est le résultat qui fait foi, et non l'absence de date de
-- décision : le corpus cite plusieurs décisions rendues sans les dater
-- (« Mixte ; 25 articles censurés (déc. 2026-903 DC) »), or celles-là ne
-- sont plus en instance ;
-- 3. la source signale la saisine sans donner de numéro d'affaire — cas de la
-- loi actualisant la programmation militaire, « Conseil constitutionnel
-- saisi le 6 juill. » (`sec10.md`, tableau B). Aucune ligne `decisions_cc`
-- ne peut être créée faute de numéro : c'est l'événement `saisine_cc`,
-- non suivi d'un `decision_cc`, qui fait foi.
--
-- Sans le troisième cas, la facette remonte 6 textes là où le §8.7 du cahier
-- des charges en attend au moins 7.
-- ============================================================================
DROP VIEW v_textes;
CREATE VIEW v_textes AS
SELECT
t.*,
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
CASE
WHEN t.statut = 'saisie_cc' THEN 1
WHEN EXISTS (SELECT 1 FROM decisions_cc d
WHERE d.texte_id = t.id
AND d.date_decision IS NULL
AND COALESCE(d.resultat, 'en_instance') = 'en_instance') THEN 1
WHEN EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'saisine_cc')
AND NOT EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'decision_cc')
AND t.statut <> 'promulguee' THEN 1
ELSE 0
END AS devant_cc
FROM textes t;
+14
View File
@@ -82,6 +82,15 @@ class Pertinence(StrEnum):
FAIBLE = "faible" FAIBLE = "faible"
class RegimeLibertes(StrEnum):
"""Cotation globale d'un texte par l'annexe du rapport (`sec10.md`)."""
DROITS_PLUS = "droits_plus" # création ou extension de droits
CONTROLE_PLUS = "controle_plus" # restriction, ou extension des contrôles
MIXTE = "mixte"
NEUTRE = "neutre"
class Confiance(StrEnum): class Confiance(StrEnum):
HIGH = "high" HIGH = "high"
MEDIUM = "medium" MEDIUM = "medium"
@@ -258,6 +267,9 @@ class Texte(BaseModel):
themes: list[Theme] = Field(default_factory=list) themes: list[Theme] = Field(default_factory=list)
impacts: dict[Public, Impact] = Field(default_factory=dict) impacts: dict[Public, Impact] = Field(default_factory=dict)
regime_libertes: RegimeLibertes | None = None
regime_libertes_note: str | None = None
guadeloupe_pertinence: Pertinence | None = None guadeloupe_pertinence: Pertinence | None = None
guadeloupe_note: str | None = None guadeloupe_note: str | None = None
@@ -353,6 +365,8 @@ class Texte(BaseModel):
{str(p): {"sens": str(i.sens), "note": i.note} for p, i in self.impacts.items()}, {str(p): {"sens": str(i.sens), "note": i.note} for p, i in self.impacts.items()},
ensure_ascii=False, ensure_ascii=False,
), ),
"regime_libertes": str(self.regime_libertes) if self.regime_libertes else None,
"regime_libertes_note": self.regime_libertes_note,
"guadeloupe_pertinence": ( "guadeloupe_pertinence": (
str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None
), ),
+5
View File
@@ -0,0 +1,5 @@
"""Parseurs du corpus de recherche.
Chaque parseur est pur : il lit un fichier de `data/input/` et retourne des
modèles pydantic. Aucun n'écrit en base ni ne modifie ses fichiers d'entrée.
"""
+154
View File
@@ -0,0 +1,154 @@
"""Lecture des dates françaises telles qu'elles apparaissent dans le corpus.
Le corpus mélange les formes : « 23 avr. 2026 », « 1er juill. 2026 »,
« 12 juin 2026 », « 24.07.2026 », « 22/07/2026 », « ~24 août 2026 »,
« Fin août 2026 », « Automne 2026 ». Les trois premières familles donnent une
date exacte ; les suivantes ne donnent qu'une intention de calendrier, qu'on ne
convertit jamais en date certaine — le libellé est conservé tel quel.
"""
from __future__ import annotations
import re
import unicodedata
from datetime import date
# Formes rencontrées dans le corpus, abrégées ou non, avec ou sans accent.
MOIS: dict[str, int] = {
"janvier": 1, "janv": 1,
"fevrier": 2, "fevr": 2, "fev": 2,
"mars": 3,
"avril": 4, "avr": 4,
"mai": 5,
"juin": 6,
"juillet": 7, "juill": 7, "juil": 7,
"aout": 8,
"septembre": 9, "sept": 9,
"octobre": 10, "oct": 10,
"novembre": 11, "nov": 11,
"decembre": 12, "dec": 12,
}
# Découpages de saison utilisés pour situer une échéance sans date exacte.
SAISONS: dict[str, tuple[int, int]] = {
"printemps": (4, 1),
"ete": (7, 1),
"automne": (10, 1),
"hiver": (1, 15),
"rentree": (9, 1),
}
_LITTERALE = re.compile(
r"\b(\d{1,2})\s*(?:er|ᵉʳ|re|ère)?\.?\s+"
r"(janvier|janv|fevrier|fevr|fev|mars|avril|avr|mai|juin|juillet|juill|juil|"
r"aout|septembre|sept|octobre|oct|novembre|nov|decembre|dec)\.?\s*(\d{4})?",
re.IGNORECASE,
)
_NUMERIQUE = re.compile(r"\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b")
_ISO = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
_MOIS_SEUL = re.compile(
r"\b(debut|mi|fin|courant)?\s*-?\s*"
r"(janvier|janv|fevrier|fevr|fev|mars|avril|avr|mai|juin|juillet|juill|juil|"
r"aout|septembre|sept|octobre|oct|novembre|nov|decembre|dec)\.?"
r"\s+(\d{4})\b",
re.IGNORECASE,
)
_SAISON = re.compile(
r"\b(printemps|ete|automne|hiver|rentree)\s+(?:de\s+)?(\d{4})\b", re.IGNORECASE
)
def sans_accents(texte: str) -> str:
"""Retire les diacritiques : « août » → « aout », « févr. » → « fevr. »."""
decompose = unicodedata.normalize("NFD", texte)
return "".join(c for c in decompose if unicodedata.category(c) != "Mn")
def lire_date(fragment: str, *, annee_defaut: int | None = None) -> date | None:
"""Extrait la première date **exacte** d'un fragment, sinon `None`.
Une date exacte suppose un jour identifié. « Fin août 2026 » ou
« Automne 2026 » renvoient `None` : voir `lire_echeance`.
"""
if not fragment:
return None
plat = sans_accents(fragment).lower()
if m := _ISO.search(plat):
return _construire(int(m.group(1)), int(m.group(2)), int(m.group(3)))
if m := _NUMERIQUE.search(plat):
return _construire(int(m.group(3)), int(m.group(2)), int(m.group(1)))
if m := _LITTERALE.search(plat):
jour = int(m.group(1))
mois = MOIS[m.group(2)]
annee = int(m.group(3)) if m.group(3) else annee_defaut
if annee is None:
return None
return _construire(annee, mois, jour)
return None
def lire_echeance(fragment: str) -> tuple[date | None, str]:
"""Interprète une échéance prévisionnelle.
Retourne `(date approchée ou None, libellé nettoyé)`. Le libellé est
toujours conservé : c'est lui qui est affiché, la date ne sert qu'au tri et
au filtrage par période.
"""
libelle = " ".join(fragment.split()).strip(" .;")
if not libelle:
return None, ""
if exacte := lire_date(libelle):
return exacte, libelle
plat = sans_accents(libelle).lower()
if m := _MOIS_SEUL.search(plat):
qualificatif, mois, annee = m.group(1), MOIS[m.group(2)], int(m.group(3))
# Un mois sans jour est situé conventionnellement : début = 5, fin = 25,
# milieu ou absence de qualificatif = 15. La date sert au tri, jamais à
# affirmer un fait — c'est le libellé qui est affiché.
jour = {"debut": 5, "fin": 25}.get(qualificatif or "", 15)
return _construire(annee, mois, jour), libelle
if m := _SAISON.search(plat):
mois, jour = SAISONS[m.group(1)]
return _construire(int(m.group(2)), mois, jour), libelle
# « Indéterminée », « Sans échéance », « Incertaine »… : aucune date.
return None, libelle
def _construire(annee: int, mois: int, jour: int) -> date | None:
try:
return date(annee, mois, jour)
except ValueError:
return None
def toutes_les_dates(fragment: str, *, annee_defaut: int | None = None) -> list[date]:
"""Toutes les dates exactes d'un fragment, dans l'ordre d'apparition.
Utile pour les cellules qui en contiennent plusieurs, par exemple
« Adoptée (20-21 juill.) ; saisine du 24 juill. ».
"""
plat = sans_accents(fragment).lower()
trouvees: list[tuple[int, date]] = []
for motif, ordre in ((_ISO, "ymd"), (_NUMERIQUE, "dmy"), (_LITTERALE, "litterale")):
for m in motif.finditer(plat):
if ordre == "ymd":
d = _construire(int(m.group(1)), int(m.group(2)), int(m.group(3)))
elif ordre == "dmy":
d = _construire(int(m.group(3)), int(m.group(2)), int(m.group(1)))
else:
annee = int(m.group(3)) if m.group(3) else annee_defaut
d = _construire(annee, MOIS[m.group(2)], int(m.group(1))) if annee else None
if d:
trouvees.append((m.start(), d))
return [d for _, d in sorted(trouvees)]
+180
View File
@@ -0,0 +1,180 @@
"""Briques de lecture du markdown du corpus.
Trois besoins récurrents : découper les tableaux, retirer le balisage d'emphase
sans perdre le texte, et récolter les marqueurs de citation `[^dim07-24^]` qui
relient un fait de chapitre à sa source dans un rapport de dimension.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^]
MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]")
_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL)
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_CODE = re.compile(r"`([^`]+)`")
_SEPARATEUR_TABLEAU = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
@dataclass(slots=True)
class Tableau:
"""Un tableau markdown, avec le titre de section qui le précède."""
entetes: list[str]
lignes: list[dict[str, str]]
section: str = ""
ligne_debut: int = 0
def __len__(self) -> int:
return len(self.lignes)
def colonne(self, prefixe: str) -> str | None:
"""Retrouve un nom d'en-tête par son début, insensible à la casse."""
prefixe = prefixe.lower()
for entete in self.entetes:
if entete.lower().startswith(prefixe):
return entete
return None
@dataclass(slots=True)
class Marqueur:
"""Une référence `[^dimXX-N^]` rencontrée dans un texte."""
brut: str # « dim07-24 »
dimension: str # « dim07 », « phase5 », « insight », ou « ref »
numero: str # « 24 », « 3.1 »
@property
def cle(self) -> str:
return f"{self.dimension}-{self.numero}" if self.numero else self.dimension
def extraire_tableaux(markdown: str) -> list[Tableau]:
"""Découpe tous les tableaux d'un document markdown.
Une ligne d'en-tête suivie d'une ligne de séparation ouvre un tableau ; la
première ligne qui ne commence pas par `|` le referme.
"""
lignes = markdown.splitlines()
tableaux: list[Tableau] = []
section = ""
i = 0
while i < len(lignes):
ligne = lignes[i]
if ligne.lstrip().startswith("#"):
section = ligne.lstrip("#").strip()
i += 1
continue
est_ligne_tableau = ligne.strip().startswith("|")
separateur_suivant = i + 1 < len(lignes) and _SEPARATEUR_TABLEAU.match(lignes[i + 1])
if est_ligne_tableau and separateur_suivant:
entetes = _decouper_ligne(ligne)
corps: list[dict[str, str]] = []
j = i + 2
while j < len(lignes) and lignes[j].strip().startswith("|"):
cellules = _decouper_ligne(lignes[j])
if any(c for c in cellules):
# Tolère les lignes plus courtes ou plus longues que l'en-tête.
cellules += [""] * (len(entetes) - len(cellules))
corps.append(dict(zip(entetes, cellules[: len(entetes)], strict=True)))
j += 1
tableaux.append(
Tableau(entetes=entetes, lignes=corps, section=section, ligne_debut=i + 1)
)
i = j
continue
i += 1
return tableaux
def _decouper_ligne(ligne: str) -> list[str]:
"""Découpe une ligne de tableau en cellules, en respectant les `\\|` échappés."""
interne = ligne.strip()
if interne.startswith("|"):
interne = interne[1:]
if interne.endswith("|"):
interne = interne[:-1]
cellules = re.split(r"(?<!\\)\|", interne)
return [c.replace(r"\|", "|").strip() for c in cellules]
def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> str:
"""Retire le balisage d'emphase, les liens et les marqueurs de citation."""
resultat = _CODE.sub(r"\1", texte)
resultat = _LIEN_MD.sub(r"\1", resultat)
resultat = _EMPHASE.sub(r"\1", resultat)
if not garder_marqueurs:
resultat = MOTIF_MARQUEUR.sub("", resultat)
return " ".join(resultat.split()).strip(" ;,")
def extraire_marqueurs(texte: str) -> list[Marqueur]:
"""Récolte les marqueurs de citation d'un fragment, sans doublon."""
marqueurs: list[Marqueur] = []
vus: set[str] = set()
for brut in MOTIF_MARQUEUR.findall(texte):
if brut in vus:
continue
vus.add(brut)
if "-" in brut:
dimension, numero = brut.split("-", 1)
elif brut.isdigit():
# Marqueur local à un fichier de dimension : [^24^]
dimension, numero = "", brut
else:
dimension, numero = brut, ""
marqueurs.append(Marqueur(brut=brut, dimension=dimension, numero=numero))
return marqueurs
@dataclass(slots=True)
class Section:
"""Un bloc de texte délimité par un titre markdown."""
niveau: int
titre: str
corps: str
ligne_debut: int
sous_sections: list[Section] = field(default_factory=list)
def decouper_sections(markdown: str, *, niveau_max: int = 3) -> list[Section]:
"""Découpe un document en sections à plat, jusqu'au niveau de titre demandé."""
lignes = markdown.splitlines()
sections: list[Section] = []
courante: Section | None = None
tampon: list[str] = []
for numero, ligne in enumerate(lignes, start=1):
entete = re.match(r"^(#{1,6})\s+(.*)$", ligne)
if entete and len(entete.group(1)) <= niveau_max:
if courante:
courante.corps = "\n".join(tampon).strip()
sections.append(courante)
courante = Section(
niveau=len(entete.group(1)),
titre=entete.group(2).strip(),
corps="",
ligne_debut=numero,
)
tampon = []
else:
tampon.append(ligne)
if courante:
courante.corps = "\n".join(tampon).strip()
sections.append(courante)
return sections
+797
View File
@@ -0,0 +1,797 @@
"""Parseur de l'annexe récapitulative (`lois-france-2026-guadeloupe_sec10.md`).
C'est le chemin le plus fiable du seed : deux tableaux tenus à jour à la main
par la recherche, avec numéros de loi, dates de promulgation, statuts au
25 juillet 2026, cotation des libertés et pertinence Guadeloupe.
- Tableau A : 27 lois promulguées du 23 avril au 23 juillet 2026.
- Tableau B : 23 lignes de textes adoptés non promulgués, en navette, déposés
ou annoncés. Deux de ces lignes portent chacune deux textes distincts, avec
des statuts différents : elles sont éclatées (voir `LIGNES_A_ECLATER`).
Le parseur ne produit que ce que les cellules disent. Tout ce qui manque —
résumé, points clés, impacts par public — est rempli plus loin dans le seed, à
partir des chapitres et des rapports de dimension.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from pipeline.journal import logger
from pipeline.modeles import (
Confiance,
DecisionCC,
Evenement,
Pertinence,
RegimeLibertes,
ResultatCC,
Statut,
Texte,
Theme,
TypeEtape,
TypeTexte,
)
from pipeline.parseurs.dates_fr import lire_date, lire_echeance, sans_accents, toutes_les_dates
from pipeline.parseurs.markdown import Tableau, extraire_marqueurs, extraire_tableaux, nettoyer
log = logger("parseur.sec10")
ANNEE_CORPUS = 2026
MOTIF_AFFAIRE = re.compile(r"\b(\d{4}-\d{1,4})\s*DC\b")
MOTIF_NUMERO_LOI = re.compile(r"^(LO\s+)?(\d{4}-\d{1,4})$", re.IGNORECASE)
# ─────────────────────────────────────────────────────────────────────────────
# Correspondances déclaratives
#
# Ces tables ne sont pas des données : ce sont des identifiants et des
# rattachements. Chaque entrée est justifiée par la ligne du corpus qu'elle
# désigne, citée en commentaire.
# ─────────────────────────────────────────────────────────────────────────────
# Identifiants canoniques du tableau B, indexés par le début de l'intitulé
# (comparaison sans accent ni casse). Les trois premiers sont imposés par le §7
# du cahier des charges ; les autres suivent la même logique de lisibilité.
SLUGS_TABLEAU_B: dict[str, str] = {
"loi « riposte »": "loi-riposte",
"ppl relative au droit a l'aide a mourir": "ppl-aide-a-mourir",
"pjl d'habilitation": "pjl-pacte-migration",
"loi « philippine »": "loi-philippine",
"ppl reseaux sociaux et mineurs": "ppl-reseaux-sociaux-mineurs",
"loi d'urgence pour la protection et la souverainete agricoles": "loi-urgence-agricole",
"loi actualisant la programmation militaire": "loi-programmation-militaire",
"ppl « pour une montagne vivante": "ppl-montagne",
"ppl relative au patrimoine immobilier": "ppl-patrimoine-immobilier-etat",
"pjl relatif a la protection des enfants": "pjl-protection-enfants",
"pjl cohesion republicaine": "pjl-cohesion-republicaine",
"pjl logement": "pjl-logement",
"accord globe": "accord-globe",
"ppl « entrisme »": "ppl-entrisme",
"pjl « separatisme »": "pjl-separatisme",
"ppl « outrage a l'hymne": "ppl-outrage-hymne-drapeau",
"ppl d'abrogation du « code noir »": "ppl-abrogation-code-noir",
"ppl « adaptation du droit des outre-mer »": "ppl-adaptation-droit-outre-mer",
"ppl « dispositif ultramarin d'emploi durable »": "ppl-emploi-ultramarin",
"ppl « entites naturelles": "ppl-entites-naturelles",
"ppl visant a mettre fin au devoir conjugal": "ppl-devoir-conjugal",
}
@dataclass(slots=True)
class Eclatement:
"""Découpe une ligne du tableau B qui décrit deux textes distincts."""
slug: str
titre: str
type: TypeTexte
statut: Statut
# Fragment de la cellule « Statut » qui concerne ce texte-là, pour que la
# datation ne mélange pas les deux parcours.
fragment_statut: str
note: str
# Deux lignes du tableau B portent deux textes chacune, avec des statuts
# différents. Les laisser fusionnées reviendrait à attribuer un statut unique à
# deux parcours parlementaires distincts.
LIGNES_A_ECLATER: dict[str, list[Eclatement]] = {
# « Accords Kazakhstan (réadmission, AN n° 2416) et Colombie (extradition,
# AN n° 2509) | Adoptés par le Sénat (28 janv. et 18 févr. 2026) ; en
# attente à l'Assemblée »
"accords kazakhstan": [
Eclatement(
slug="accord-kazakhstan-readmission",
titre="Accord France-Kazakhstan de réadmission (AN n° 2416)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.NAVETTE,
fragment_statut="Adopté par le Sénat le 28 janv. 2026 ; en attente à l'Assemblée",
note="Réadmission vers des « hubs » tiers (clause de non-refoulement)",
),
Eclatement(
slug="accord-colombie-extradition",
titre="Accord France-Colombie d'extradition (AN n° 2509)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.NAVETTE,
fragment_statut="Adopté par le Sénat le 18 févr. 2026 ; en attente à l'Assemblée",
note="Extradition",
),
],
# « MACF : ratification de l'ordonnance n° 2026-326 et accord « plateforme
# centrale commune » | Ratification déposée au Sénat, non examinée
# (~10 juill.) ; accord plateforme en attente de dépôt »
"macf : ratification": [
Eclatement(
slug="pjl-ratification-ordonnance-macf",
titre="Ratification de l'ordonnance n° 2026-326 (mécanisme d'ajustement "
"carbone aux frontières)",
type=TypeTexte.PJL,
statut=Statut.DEPOSEE_NON_EXAMINEE,
fragment_statut="Déposée au Sénat, non examinée (~10 juill. 2026)",
note="Obligations déclaratives des importateurs ; négoces guadeloupéens "
"de matériaux tiers concernés",
),
Eclatement(
slug="accord-macf-plateforme-centrale",
titre="Accord « plateforme centrale commune » (MACF)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.ANNONCEE,
fragment_statut="En attente de dépôt",
note="Obligations déclaratives des importateurs",
),
],
}
# Rattachement des décisions du Conseil constitutionnel rendues sur les lois du
# tableau A. Les numéros figurent dans la colonne « Statut libertés », le
# résultat dans `cross_verification.md` §1.
RESULTATS_CC_CONNUS: dict[str, tuple[ResultatCC, str]] = {
"2026-903 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"25 articles censurés (ZFE art. 37, dérogations ZAN art. 35 §IV)",
),
"2026-904 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"Articles 10, 12 (numéro d'identification des associations) et 32 censurés "
"comme cavaliers ; 6 réserves",
),
"2026-905 DC": (ResultatCC.CONFORME, "Conforme"),
"2026-906 DC": (
ResultatCC.CONFORME_AVEC_RESERVES,
"Conforme avec réserves (décision du 23 juillet 2026)",
),
"2026-908 DC": (ResultatCC.CONFORME, "Conforme"),
"2026-909 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"Censure du portrait-robot génétique et des bases ADN étrangères",
),
}
# Mots-clés de thème. Volontairement conservateurs : mieux vaut un thème
# manquant qu'un thème inventé — les chapitres affinent en phase suivante.
MOTS_CLES_THEMES: dict[Theme, tuple[str, ...]] = {
Theme.JUSTICE: (
"justice", "penal", "criminel", "juge", "avocat", "detention", "victimes",
"privation de liberte", "extradition", "defense", "juridiction",
"consentement", "conjugal", "mariage", "outrage", "delit", "amende",
),
Theme.SECURITE: (
"securite", "ordre public", "police", "retention", "surveillance", "fouille",
"attentat", "tranquillite", "militaire", "defense nationale", "rodeo",
),
Theme.NUMERIQUE: (
"numerique", "reseaux sociaux", "donnees de connexion", "en ligne",
"plateforme", "geolocalisation", "facturation electronique",
),
Theme.SOCIAL: (
"social", "chomage", "travail", "emploi", "petite enfance", "conge",
"professionnalisation", "logement", "formation", "salarie", "enfant",
),
Theme.FISCAL: ("fiscal", "impot", "taxe", "fraude", "conventions fiscales", "octroi de mer"),
Theme.ENVIRONNEMENT: (
"environnement", "ecolog", "inondation", "hydroelectric", "eau", "littoral",
"carbone", "pesticide", "neonicotinoide", "montagne", "zan", "textile",
),
Theme.AGRICULTURE: ("agricole", "agriculture", "exploitation", "peche", "elevage"),
Theme.SANTE: (
"sante", "medecin", "soins", "palliatif", "malade", "handicap", "chlordecone",
"aide a mourir", "psychiatrique", "protoxyde",
),
Theme.MEMOIRE_PATRIMOINE: (
"memoire", "patrimoine", "restitution", "biens culturels", "restes humains",
"code noir", "reparation", "esclavage", "transplantes",
),
Theme.OUTRE_MER: (
"outre-mer", "outre mer", "ultramarin", "guadeloupe", "martinique", "guyane",
"reunion", "mayotte", "drom", "caricom", "caraibe", "saint-martin",
"nouvelle-caledonie", "calédonie", "kali'na", "arawak",
),
Theme.ECONOMIE: (
"economique", "entreprise", "commercial", "creances", "concentration",
"marches publics", "simplification", "pme", "importateur", "concurrence",
),
Theme.MIGRATION: ("migration", "asile", "readmission", "etranger", "frontiere", "expulsion"),
Theme.INSTITUTIONS: (
"organique", "corps electoral", "senatoriales", "constitution", "habilitation",
"collectivite", "prefet", "prefectoral", "etat", "association", "republicain",
"subvention", "dissolution", "hymne", "drapeau", "expression",
),
}
@dataclass(slots=True)
class ResultatSeed:
"""Ce qu'un parseur rend au seed : des textes et un compte-rendu."""
textes: list[Texte] = field(default_factory=list)
avertissements: list[str] = field(default_factory=list)
def __len__(self) -> int:
return len(self.textes)
# ─────────────────────────────────────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────────────────────────────────────
def parser(markdown: str) -> ResultatSeed:
"""Extrait tous les textes de l'annexe récapitulative."""
resultat = ResultatSeed()
tableaux = extraire_tableaux(markdown)
tableau_a = _trouver(tableaux, "N° de loi")
tableau_b = _trouver(tableaux, "Texte")
if tableau_a is None:
resultat.avertissements.append("Tableau A introuvable dans sec10.md")
else:
for ligne in tableau_a.lignes:
try:
resultat.textes.append(_lire_loi_promulguee(ligne))
except Exception as erreur: # noqa: BLE001 — on continue le lot
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
if tableau_b is None:
resultat.avertissements.append("Tableau B introuvable dans sec10.md")
else:
for ligne in tableau_b.lignes:
try:
resultat.textes.extend(_lire_texte_en_cours(ligne))
except Exception as erreur: # noqa: BLE001
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
log.info(
"annexe analysée",
promulguees=len(tableau_a.lignes) if tableau_a else 0,
en_cours=len(tableau_b.lignes) if tableau_b else 0,
textes=len(resultat.textes),
avertissements=len(resultat.avertissements),
)
return resultat
def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
for tableau in tableaux:
if tableau.entetes and tableau.entetes[0] == premiere_colonne:
return tableau
return None
# ─────────────────────────────────────────────────────────────────────────────
# Tableau A — lois promulguées
# ─────────────────────────────────────────────────────────────────────────────
def _lire_loi_promulguee(ligne: dict[str, str]) -> Texte:
brut_numero = nettoyer(ligne["N° de loi"])
correspondance = MOTIF_NUMERO_LOI.match(brut_numero)
if not correspondance:
raise ValueError(f"numéro de loi illisible : {brut_numero!r}")
organique = bool(correspondance.group(1))
numero = correspondance.group(2)
cellule_dates = ligne["Promulgation / JO"]
dates = toutes_les_dates(cellule_dates, annee_defaut=ANNEE_CORPUS)
if not dates:
raise ValueError(f"date de promulgation illisible : {cellule_dates!r}")
date_promulgation = dates[0]
date_jo = dates[1] if len(dates) > 1 else None
objet = nettoyer(ligne["Objet"])
cellule_libertes = ligne["Statut libertés"]
cellule_gpe = ligne["Pertinence Guadeloupe-OM"]
regime, note_regime = _lire_regime(cellule_libertes)
pertinence, note_gpe = _lire_pertinence(cellule_gpe)
evenements = [
Evenement(
date_evenement=date_promulgation,
type_etape=TypeEtape.PROMULGATION,
description=f"Promulgation de la loi n° {numero}",
)
]
if date_jo:
evenements.append(
Evenement(
date_evenement=date_jo,
type_etape=TypeEtape.PUBLICATION_JO,
description="Publication au Journal officiel",
)
)
# Une date postérieure à la promulgation, citée dans la colonne Guadeloupe,
# est une entrée en vigueur différée (« 1er sept. 2026 », « 6 janv. 2027 »).
entree_vigueur = _entree_en_vigueur_differee(cellule_gpe, date_promulgation)
if entree_vigueur:
evenements.append(
Evenement(
date_evenement=entree_vigueur,
type_etape=TypeEtape.ENTREE_VIGUEUR,
description=f"Entrée en vigueur différée au {entree_vigueur.isoformat()}",
previsionnel=entree_vigueur > date_promulgation,
)
)
decisions = _lire_decisions_cc(f"{cellule_libertes} {cellule_gpe}")
return Texte(
id=f"loi-{numero}",
numero_officiel=numero,
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
titre_court=objet,
statut=Statut.PROMULGUEE,
statut_date=date_promulgation,
date_promulgation=date_promulgation,
date_entree_vigueur=entree_vigueur,
themes=_deduire_themes(f"{objet} {cellule_libertes} {cellule_gpe}"),
regime_libertes=regime,
regime_libertes_note=note_regime,
guadeloupe_pertinence=pertinence,
guadeloupe_note=note_gpe,
confiance=Confiance.HIGH,
source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-a",
evenements=evenements,
decisions_cc=decisions,
)
def _entree_en_vigueur_differee(cellule: str, promulgation: date) -> date | None:
"""Repère une date d'application postérieure à la promulgation."""
for candidate in toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS):
if candidate > promulgation:
return candidate
return None
# ─────────────────────────────────────────────────────────────────────────────
# Tableau B — textes en cours
# ─────────────────────────────────────────────────────────────────────────────
def _lire_texte_en_cours(ligne: dict[str, str]) -> list[Texte]:
intitule = nettoyer(ligne["Texte"])
cle = sans_accents(intitule).lower()
cellule_statut = ligne["Statut au 25 juillet 2026"]
cellule_etape = nettoyer(ligne["Prochaine étape"])
cellule_echeance = nettoyer(ligne["Échéance probable"])
cellule_enjeu = nettoyer(ligne["Enjeu libertés principal"])
echeance, libelle_echeance = lire_echeance(cellule_echeance)
# La date de saisine se lit dans la cellule de statut uniquement : la
# colonne « Échéance probable » ne contient que la décision attendue, et
# la confondre avec la saisine daterait celle-ci du mois suivant.
decisions = _lire_decisions_cc(cellule_statut, echeance_par_defaut=echeance)
for prefixe, eclatements in LIGNES_A_ECLATER.items():
if cle.startswith(prefixe):
return [
_construire_texte_en_cours(
slug=e.slug,
titre=e.titre,
type_texte=e.type,
statut=e.statut,
cellule_statut=e.fragment_statut,
etape=cellule_etape,
echeance=echeance,
libelle_echeance=libelle_echeance,
enjeu=e.note or cellule_enjeu,
decisions=[],
)
for e in eclatements
]
slug = _slug_tableau_b(cle, intitule)
statut = _lire_statut(cellule_statut)
type_texte = _lire_type(intitule)
return [
_construire_texte_en_cours(
slug=slug,
titre=intitule,
type_texte=type_texte,
statut=statut,
cellule_statut=cellule_statut,
etape=cellule_etape,
echeance=echeance,
libelle_echeance=libelle_echeance,
enjeu=cellule_enjeu,
decisions=decisions,
)
]
def _construire_texte_en_cours(
*,
slug: str,
titre: str,
type_texte: TypeTexte,
statut: Statut,
cellule_statut: str,
etape: str,
echeance: date | None,
libelle_echeance: str,
enjeu: str,
decisions: list[DecisionCC],
) -> Texte:
statut_propre = nettoyer(cellule_statut)
evenements = _evenements_du_statut(cellule_statut, statut)
label = etape
if libelle_echeance and libelle_echeance.lower() not in etape.lower():
label = f"{etape}{libelle_echeance}" if etape else libelle_echeance
return Texte(
id=slug,
type=type_texte,
titre_court=titre,
statut=statut,
statut_date=_date_du_statut(cellule_statut),
date_adoption=_date_adoption(cellule_statut, statut),
prochaine_echeance=echeance,
prochaine_echeance_label=label or None,
themes=_deduire_themes(f"{titre} {statut_propre} {enjeu}"),
regime_libertes=None, # le tableau B ne cote pas les régimes
guadeloupe_pertinence=None, # renseigné en phase d'enrichissement
points_cles=[enjeu] if enjeu else [],
confiance=Confiance.HIGH,
source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-b",
evenements=evenements,
decisions_cc=decisions,
)
def _slug_tableau_b(cle: str, intitule: str) -> str:
for prefixe, slug in SLUGS_TABLEAU_B.items():
if cle.startswith(prefixe):
return slug
# Repli : slug dérivé de l'intitulé, tronqué pour rester lisible en URL.
mots = re.sub(r"[^a-z0-9]+", "-", sans_accents(intitule).lower()).strip("-")
return "-".join(mots.split("-")[:6])
# ─────────────────────────────────────────────────────────────────────────────
# Lecture des cellules
# ─────────────────────────────────────────────────────────────────────────────
def _lire_regime(cellule: str) -> tuple[RegimeLibertes | None, str | None]:
"""Lit la colonne « Statut libertés » : droits+, contrôle+, mixte, neutre."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
if "droits+" in plat and "neutre" not in plat.split(":")[0]:
regime = RegimeLibertes.DROITS_PLUS
elif "controle+" in plat:
regime = RegimeLibertes.CONTROLE_PLUS
elif plat.startswith("mixte"):
regime = RegimeLibertes.MIXTE
elif "droits+" in plat:
# « Neutre à droits+ » : la source hésite, on retient le sens positif.
regime = RegimeLibertes.DROITS_PLUS
elif plat.startswith("neutre"):
regime = RegimeLibertes.NEUTRE
else:
return None, propre or None
return regime, propre or None
def _lire_pertinence(cellule: str) -> tuple[Pertinence | None, str | None]:
"""Lit « Forte — motif », « Moyenne — motif », « Faible — motif »."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
pertinence: Pertinence | None = None
if plat.startswith("forte"):
pertinence = Pertinence.FORTE
elif plat.startswith("moyenne"):
pertinence = Pertinence.MOYENNE
elif plat.startswith("faible"):
pertinence = Pertinence.FAIBLE
note = propre
for separateur in ("", "", "-"):
if separateur in propre:
note = propre.split(separateur, 1)[1].strip()
break
return pertinence, note or None
# Formes du corpus, testées dans cet ordre : la première qui apparaît gagne.
_FORMES_STATUT: tuple[tuple[str, Statut], ...] = (
("adoptee, non promulguee", Statut.ADOPTEE_NON_PROMULGUEE),
("adopte, non promulgue", Statut.ADOPTEE_NON_PROMULGUEE),
("deposee, non examinee", Statut.DEPOSEE_NON_EXAMINEE),
("depose, non examine", Statut.DEPOSEE_NON_EXAMINEE),
("en navette", Statut.NAVETTE),
("adoptee par une chambre", Statut.NAVETTE),
("adoptee en 1re lecture", Statut.NAVETTE),
("adoptes par le senat", Statut.NAVETTE),
("ratification deposee", Statut.DEPOSEE_NON_EXAMINEE),
("annonce", Statut.ANNONCEE),
("rejete", Statut.REJETEE),
)
def _lire_statut(cellule: str) -> Statut:
"""Traduit la cellule de statut du tableau B en valeur d'énumération."""
plat = sans_accents(nettoyer(cellule)).lower()
# Cas particulier documenté : la loi « Philippine » est adoptée ET déjà
# validée par le Conseil constitutionnel (2026-906 DC du 23 juillet), il ne
# lui manque que la signature. C'est exactement `validee_cc`.
if "conforme" in plat and "promulgation pendante" in plat:
return Statut.VALIDEE_CC
for forme, statut in _FORMES_STATUT:
if forme in plat:
return statut
if plat.startswith("adoptee") or plat.startswith("adopte"):
return Statut.ADOPTEE_NON_PROMULGUEE
raise ValueError(f"statut non reconnu : {nettoyer(cellule)!r}")
def _date_du_statut(cellule: str) -> date | None:
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
return max(dates) if dates else None
def _date_adoption(cellule: str, statut: Statut) -> date | None:
"""Première date citée quand la cellule décrit une adoption."""
if statut in (Statut.DEPOSEE_NON_EXAMINEE, Statut.ANNONCEE):
return None
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
return dates[0] if dates else None
def _evenements_du_statut(cellule: str, statut: Statut) -> list[Evenement]:
"""Reconstruit la timeline lisible dans la cellule de statut."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
evenements: list[Evenement] = []
if not dates:
return evenements
if statut is Statut.ADOPTEE_NON_PROMULGUEE or "adopt" in plat:
etape = (
TypeEtape.ADOPTION_DEFINITIVE
if statut in (Statut.ADOPTEE_NON_PROMULGUEE, Statut.VALIDEE_CC)
else TypeEtape.ADOPTION_1RE_LECTURE
)
evenements.append(
Evenement(date_evenement=dates[0], type_etape=etape, description=propre)
)
elif statut is Statut.DEPOSEE_NON_EXAMINEE:
evenements.append(
Evenement(date_evenement=dates[0], type_etape=TypeEtape.DEPOT, description=propre)
)
# Une saisine du Conseil constitutionnel est un événement à part entière :
# le §7 du cahier des charges l'exige explicitement pour la loi « Riposte ».
# Attention : une cellule peut citer une décision SANS saisine (la loi
# « Philippine » ne mentionne que « déc. 2026-906 DC du 23 juill. »).
# Confondre les deux inventerait une saisine à la date de la décision.
affaires = MOTIF_AFFAIRE.findall(cellule)
date_saisine = _date_de_saisine(cellule)
if _mentionne_une_saisine(cellule):
for affaire in affaires:
quand = date_saisine or (dates[-1] if dates else None)
if quand:
evenements.append(
Evenement(
date_evenement=quand,
type_etape=TypeEtape.SAISINE_CC,
description=f"Saisine du Conseil constitutionnel n° {affaire} DC "
f"du {_en_toutes_lettres(quand)}",
)
)
# Certaines lignes mentionnent la saisine sans numéro d'affaire — la loi
# actualisant la programmation militaire, « Conseil constitutionnel saisi
# le 6 juill. ». L'événement est enregistré quand même : c'est lui qui
# fait apparaître le texte dans la facette « devant le Conseil
# constitutionnel ». Aucune `decision_cc` n'est créée, faute de numéro.
if not affaires and date_saisine:
evenements.append(
Evenement(
date_evenement=date_saisine,
type_etape=TypeEtape.SAISINE_CC,
description="Saisine du Conseil constitutionnel du "
f"{_en_toutes_lettres(date_saisine)} (numéro d'affaire non "
"précisé par la source)",
)
)
# Décision déjà rendue et datée dans la cellule.
for affaire in affaires:
rendue = _date_de_decision(cellule, affaire)
if rendue:
evenements.append(
Evenement(
date_evenement=rendue,
type_etape=TypeEtape.DECISION_CC,
description=f"Décision n° {affaire} DC du {_en_toutes_lettres(rendue)}",
)
)
return evenements
def _mentionne_une_saisine(cellule: str) -> bool:
plat = sans_accents(nettoyer(cellule)).lower()
return "conseil constitutionnel saisi" in plat or "saisin" in plat
_MENTION_SAISINE = re.compile(r"saisi\w*", re.IGNORECASE)
def _date_de_saisine(cellule: str) -> date | None:
"""Date rattachée au mot « saisine », jamais au vote d'adoption.
La recherche est bornée au fragment qui **suit** la mention de la saisine.
Sans cette borne, « Adoptée (15 juill., 291/241) ; quatre saisines réunies
sous le n° 2026-910 DC » daterait la saisine du jour du vote — alors que le
corpus ne la date pas du tout. Mieux vaut aucune date qu'une date fausse.
Formes couvertes : « saisine du 23 juill. », « saisines des 23-24 juill. »
(on retient la dernière), « Conseil constitutionnel saisi le 6 juill. ».
"""
plat = sans_accents(cellule)
mention = _MENTION_SAISINE.search(plat)
if not mention:
return None
apres = cellule[mention.end() :]
dates = toutes_les_dates(apres, annee_defaut=ANNEE_CORPUS)
return dates[-1] if dates else None
_MOIS_LETTRES = (
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
)
def _en_toutes_lettres(d: date) -> str:
jour = "1er" if d.day == 1 else str(d.day)
return f"{jour} {_MOIS_LETTRES[d.month - 1]} {d.year}"
def _lire_type(intitule: str) -> TypeTexte:
plat = sans_accents(intitule).lower()
if plat.startswith("ppl"):
return TypeTexte.PPL
if plat.startswith("pjl"):
return TypeTexte.PJL
if plat.startswith(("accord", "accords")):
return TypeTexte.ACCORD_INTERNATIONAL
if "organique" in plat:
return TypeTexte.LOI_ORGANIQUE
if plat.startswith("loi"):
return TypeTexte.LOI
return TypeTexte.PJL
def _lire_decisions_cc(
fragment: str, *, echeance_par_defaut: date | None = None
) -> list[DecisionCC]:
"""Extrait les affaires « n° 2026-915 DC » citées dans une cellule."""
decisions: list[DecisionCC] = []
vues: set[str] = set()
for numero in MOTIF_AFFAIRE.findall(fragment):
affaire = f"{numero} DC"
if affaire in vues:
continue
vues.add(affaire)
connue = RESULTATS_CC_CONNUS.get(affaire)
if connue:
resultat, resume = connue
decisions.append(
DecisionCC(
numero_affaire=affaire,
date_decision=_date_de_decision(fragment, affaire),
resultat=resultat,
resume=resume,
)
)
else:
decisions.append(
DecisionCC(
numero_affaire=affaire,
date_saisine=_date_de_saisine(fragment),
resultat=ResultatCC.EN_INSTANCE,
date_decision_attendue=echeance_par_defaut,
)
)
return decisions
_DECISION_DU = re.compile(r"dec\.?\s*\d{4}-\d{1,4}\s*DC\s*du\s+([^);,]+)", re.IGNORECASE)
def _date_de_decision(fragment: str, affaire: str) -> date | None:
plat = sans_accents(fragment)
if m := _DECISION_DU.search(plat):
return lire_date(m.group(1), annee_defaut=ANNEE_CORPUS)
return None
def _compiler_motifs_themes() -> dict[Theme, re.Pattern[str]]:
"""Compile un motif par thème, ancré sur des frontières de mot.
Sans cette ancre, « eau » se déclenche sur « résEAUx », « RetaillEAU » et
« drapEAU » — trois faux positifs observés sur le corpus réel. Le suffixe
`\\w*` reste autorisé pour attraper les flexions (« agricole/agricoles »).
"""
motifs: dict[Theme, re.Pattern[str]] = {}
for theme, mots in MOTS_CLES_THEMES.items():
alternatives = "|".join(re.escape(mot) for mot in mots)
motifs[theme] = re.compile(rf"(?<!\w)(?:{alternatives})\w*", re.IGNORECASE)
return motifs
MOTIFS_THEMES = _compiler_motifs_themes()
def _deduire_themes(texte: str) -> list[Theme]:
"""Thèmes déduits par mots-clés, sans jamais dépasser quatre entrées."""
plat = sans_accents(texte).lower()
scores: list[tuple[int, Theme]] = []
for theme, motif in MOTIFS_THEMES.items():
occurrences = len(set(motif.findall(plat)))
if occurrences:
scores.append((occurrences, theme))
scores.sort(key=lambda paire: (-paire[0], str(paire[1])))
return [theme for _, theme in scores[:4]]
def extraire_marqueurs_annexe(markdown: str) -> dict[str, list[str]]:
"""Associe chaque ligne de tableau aux marqueurs de citation qu'elle porte.
Sert au rattachement des sources en phase suivante : la clé est le numéro de
loi ou l'intitulé, la valeur la liste des marqueurs bruts (« dim07-24 »).
"""
associations: dict[str, list[str]] = {}
for tableau in extraire_tableaux(markdown):
if not tableau.entetes:
continue
colonne_cle = tableau.entetes[0]
for ligne in tableau.lignes:
cle = nettoyer(ligne[colonne_cle])
marqueurs = [
m.brut for cellule in ligne.values() for m in extraire_marqueurs(cellule)
]
if marqueurs:
associations.setdefault(cle, []).extend(marqueurs)
return associations
+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