"""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"(? 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