Files
Cyber MawonajandClaude Opus 5 c28243cf87 Phase 3 : sources, citations et seed complet — 368 sources, 0 texte orphelin
Parseurs :
- blocs_claim : les quatre dialectes des rapports de dimension. Le cahier des
  charges en postulait un seul ; sans les quatre, dim05 à dim12 étaient perdus,
  soit les dimensions associations, entreprises, migration et calendrier.
- notes_bas_page : 258 notes, dont 104 sans URL. Les identifiants JORFTEXT
  donnent lieu à une URL canonique reconstruite (schéma fixe, rien d'inventé) ;
  les autres restent sans lien plutôt que d'être devinées.
- bibliographie : 481 références, toutes liées. Indexées par numéro de loi,
  par affaire DC et par cote de document parlementaire — cette dernière étant
  la seule identité d'un texte déposé et non examiné.
- chapitres : rattachement phrase par phrase, résumés repris tels quels.

Résolution des citations : 100 % des 332 marqueurs du rapport, 93 % avec un
lien vérifiable. Il a fallu six conventions de numérotation, une par famille de
dimension (marqueur de fin, en-tête de claim, section, rang, note, section.rang).

Trois pièges corrigés, tous couverts par des tests :
- « décision n° 2026-903 DC » lu comme une loi, ce qui volait à la loi 2026-403
  les phrases — et donc les sources — qui parlaient d'elle
- retrait des marqueurs laissant « … dans les DROM . » ; corrigé à la source
  sans toucher aux espaces avant « ; » et « » » qu'exige le français
- « programmation militaire 2024-2030 » lu comme la loi n° 2024-2030

make seed : 52 textes, 368 sources, 83 événements, 12 affaires CC, 7 analyses.
Zéro texte sans source URL (critère §8.2). Les 2 textes sans résumé et les 17
pertinences déduites sont marqués « à vérifier » avec leur motif.

99 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:43:03 -04:00

819 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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