Files

819 lines
33 KiB
Python
Raw Permalink Normal View History

"""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)
# Marqueurs de citation portés par la ligne d'annexe de chaque texte.
# Les lignes éclatées transmettent les leurs aux deux textes issus d'elles :
# sans cela, l'accord « plateforme centrale » MACF n'hériterait d'aucune
# source, alors que sa ligne d'origine en cite trois.
marqueurs: dict[str, list[str]] = field(default_factory=dict)
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:
texte = _lire_loi_promulguee(ligne)
resultat.textes.append(texte)
resultat.marqueurs[texte.id] = _marqueurs_de_ligne(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:
issus = _lire_texte_en_cours(ligne)
marqueurs = _marqueurs_de_ligne(ligne)
resultat.textes.extend(issus)
for texte in issus:
resultat.marqueurs[texte.id] = list(marqueurs)
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 _marqueurs_de_ligne(ligne: dict[str, str]) -> list[str]:
"""Marqueurs de citation portés par une ligne, toutes colonnes confondues."""
marqueurs: list[str] = []
for cellule in ligne.values():
for marqueur in extraire_marqueurs(cellule):
if marqueur.brut not in marqueurs:
marqueurs.append(marqueur.brut)
return marqueurs
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