Phase 2 : parseur de l'annexe sec10 — 52 textes, 12 affaires CC
Briques réutilisables : - dates_fr : lit les formes réelles du corpus (« 23 avr. 2026 (JO 24 avr.) », « 1er juill. », « 24.07.2026 ») et distingue une date exacte d'une échéance prévisionnelle (« Fin août 2026 » → situé au 25, libellé conservé, jamais présenté comme un fait) - markdown : découpage des tableaux (barres échappées, lignes courtes), nettoyage, extraction des marqueurs [^dim07-24^] / [^phase5-3.1^] Parseur sec10 : - 27 lois promulguées + 21 lignes simples + 2 lignes éclatées en 4 textes = 52 - éclatement justifié : les lignes « accords Kazakhstan et Colombie » et « MACF » décrivent chacune deux textes de statuts différents - migration 002 : regime_libertes stocké à part, faute de savoir quel public la cotation globale du rapport vise — dérouler « droits+ » sur les trois publics aurait inventé une ventilation absente de la source - migration 003 : la facette « devant le CC » couvre aussi les saisines sans numéro d'affaire (programmation militaire), sinon 6 textes au lieu de 7 Trois pièges du corpus, corrigés et couverts par des tests : - « eau » matchait comme sous-chaîne dans réseaux / drapeau / Retailleau - une décision citée sans saisine créait une fausse saisine (loi Philippine) - la date de saisine était pêchée dans la colonne « échéance », datant la saisine de l'aide à mourir du mois suivant 72 tests, 90 % de couverture sur pipeline/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
31d3935bce
commit
c36b8dc631
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user