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>
798 lines
32 KiB
Python
798 lines
32 KiB
Python
"""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
|