diff --git a/pipeline/db.py b/pipeline/db.py index 3d5e644..39de9bf 100644 --- a/pipeline/db.py +++ b/pipeline/db.py @@ -101,7 +101,8 @@ _CHAMPS_TEXTE = ( "id", "numero_officiel", "type", "titre_court", "titre_officiel", "statut", "statut_date", "date_depot", "date_adoption", "date_promulgation", "date_entree_vigueur", "prochaine_echeance", "prochaine_echeance_label", - "themes", "impacts", "guadeloupe_pertinence", "guadeloupe_note", + "themes", "impacts", "regime_libertes", "regime_libertes_note", + "guadeloupe_pertinence", "guadeloupe_note", "resume", "points_cles", "confiance", "source_seed", "a_verifier", "motif_verification", ) diff --git a/pipeline/migrations/002_regime_libertes.sql b/pipeline/migrations/002_regime_libertes.sql new file mode 100644 index 0000000..373d758 --- /dev/null +++ b/pipeline/migrations/002_regime_libertes.sql @@ -0,0 +1,32 @@ +-- ============================================================================ +-- Migration 002 — régime de libertés +-- +-- L'annexe du rapport (`sec10.md`, note méthodologique) cote chaque texte selon +-- trois régimes : « droits+ » (création ou extension de droits), « contrôle+ » +-- (restriction, ou extension des pouvoirs de contrôle), « mixte » ou « neutre ». +-- +-- Cette cotation porte sur le texte dans son ensemble : elle ne dit pas quel +-- public est touché. Elle est donc stockée à part, et non dépliée d'office dans +-- `impacts` — remplir les trois publics à partir d'une cotation globale +-- reviendrait à inventer une ventilation que la source ne donne pas. +-- ============================================================================ + +ALTER TABLE textes ADD COLUMN regime_libertes TEXT + CHECK (regime_libertes IN ('droits_plus', 'controle_plus', 'mixte', 'neutre')); + +ALTER TABLE textes ADD COLUMN regime_libertes_note TEXT; + +CREATE INDEX idx_textes_regime ON textes (regime_libertes); + +DROP VIEW v_textes; + +CREATE VIEW v_textes AS +SELECT + t.*, + (SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources, + (SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements, + CASE WHEN t.statut = 'saisie_cc' + OR EXISTS (SELECT 1 FROM decisions_cc d + WHERE d.texte_id = t.id AND d.date_decision IS NULL) + THEN 1 ELSE 0 END AS devant_cc +FROM textes t; diff --git a/pipeline/migrations/003_vue_devant_cc.sql b/pipeline/migrations/003_vue_devant_cc.sql new file mode 100644 index 0000000..c74f193 --- /dev/null +++ b/pipeline/migrations/003_vue_devant_cc.sql @@ -0,0 +1,43 @@ +-- ============================================================================ +-- Migration 003 — la facette « devant le Conseil constitutionnel » +-- +-- Trois situations désignent un texte suspendu au Conseil constitutionnel, et +-- le corpus les documente inégalement : +-- +-- 1. le statut vaut littéralement `saisie_cc` ; +-- 2. une affaire est enregistrée avec le résultat `en_instance` (« 2026-915 +-- DC ») — c'est le résultat qui fait foi, et non l'absence de date de +-- décision : le corpus cite plusieurs décisions rendues sans les dater +-- (« Mixte ; 25 articles censurés (déc. 2026-903 DC) »), or celles-là ne +-- sont plus en instance ; +-- 3. la source signale la saisine sans donner de numéro d'affaire — cas de la +-- loi actualisant la programmation militaire, « Conseil constitutionnel +-- saisi le 6 juill. » (`sec10.md`, tableau B). Aucune ligne `decisions_cc` +-- ne peut être créée faute de numéro : c'est l'événement `saisine_cc`, +-- non suivi d'un `decision_cc`, qui fait foi. +-- +-- Sans le troisième cas, la facette remonte 6 textes là où le §8.7 du cahier +-- des charges en attend au moins 7. +-- ============================================================================ + +DROP VIEW v_textes; + +CREATE VIEW v_textes AS +SELECT + t.*, + (SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources, + (SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements, + CASE + WHEN t.statut = 'saisie_cc' THEN 1 + WHEN EXISTS (SELECT 1 FROM decisions_cc d + WHERE d.texte_id = t.id + AND d.date_decision IS NULL + AND COALESCE(d.resultat, 'en_instance') = 'en_instance') THEN 1 + WHEN EXISTS (SELECT 1 FROM evenements e + WHERE e.texte_id = t.id AND e.type_etape = 'saisine_cc') + AND NOT EXISTS (SELECT 1 FROM evenements e + WHERE e.texte_id = t.id AND e.type_etape = 'decision_cc') + AND t.statut <> 'promulguee' THEN 1 + ELSE 0 + END AS devant_cc +FROM textes t; diff --git a/pipeline/modeles.py b/pipeline/modeles.py index 26a36ad..d051e10 100644 --- a/pipeline/modeles.py +++ b/pipeline/modeles.py @@ -82,6 +82,15 @@ class Pertinence(StrEnum): FAIBLE = "faible" +class RegimeLibertes(StrEnum): + """Cotation globale d'un texte par l'annexe du rapport (`sec10.md`).""" + + DROITS_PLUS = "droits_plus" # création ou extension de droits + CONTROLE_PLUS = "controle_plus" # restriction, ou extension des contrôles + MIXTE = "mixte" + NEUTRE = "neutre" + + class Confiance(StrEnum): HIGH = "high" MEDIUM = "medium" @@ -258,6 +267,9 @@ class Texte(BaseModel): themes: list[Theme] = Field(default_factory=list) impacts: dict[Public, Impact] = Field(default_factory=dict) + regime_libertes: RegimeLibertes | None = None + regime_libertes_note: str | None = None + guadeloupe_pertinence: Pertinence | None = None guadeloupe_note: str | None = None @@ -353,6 +365,8 @@ class Texte(BaseModel): {str(p): {"sens": str(i.sens), "note": i.note} for p, i in self.impacts.items()}, ensure_ascii=False, ), + "regime_libertes": str(self.regime_libertes) if self.regime_libertes else None, + "regime_libertes_note": self.regime_libertes_note, "guadeloupe_pertinence": ( str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None ), diff --git a/pipeline/parseurs/__init__.py b/pipeline/parseurs/__init__.py new file mode 100644 index 0000000..1b17e6e --- /dev/null +++ b/pipeline/parseurs/__init__.py @@ -0,0 +1,5 @@ +"""Parseurs du corpus de recherche. + +Chaque parseur est pur : il lit un fichier de `data/input/` et retourne des +modèles pydantic. Aucun n'écrit en base ni ne modifie ses fichiers d'entrée. +""" diff --git a/pipeline/parseurs/dates_fr.py b/pipeline/parseurs/dates_fr.py new file mode 100644 index 0000000..6f77042 --- /dev/null +++ b/pipeline/parseurs/dates_fr.py @@ -0,0 +1,154 @@ +"""Lecture des dates françaises telles qu'elles apparaissent dans le corpus. + +Le corpus mélange les formes : « 23 avr. 2026 », « 1er juill. 2026 », +« 12 juin 2026 », « 24.07.2026 », « 22/07/2026 », « ~24 août 2026 », +« Fin août 2026 », « Automne 2026 ». Les trois premières familles donnent une +date exacte ; les suivantes ne donnent qu'une intention de calendrier, qu'on ne +convertit jamais en date certaine — le libellé est conservé tel quel. +""" + +from __future__ import annotations + +import re +import unicodedata +from datetime import date + +# Formes rencontrées dans le corpus, abrégées ou non, avec ou sans accent. +MOIS: dict[str, int] = { + "janvier": 1, "janv": 1, + "fevrier": 2, "fevr": 2, "fev": 2, + "mars": 3, + "avril": 4, "avr": 4, + "mai": 5, + "juin": 6, + "juillet": 7, "juill": 7, "juil": 7, + "aout": 8, + "septembre": 9, "sept": 9, + "octobre": 10, "oct": 10, + "novembre": 11, "nov": 11, + "decembre": 12, "dec": 12, +} + +# Découpages de saison utilisés pour situer une échéance sans date exacte. +SAISONS: dict[str, tuple[int, int]] = { + "printemps": (4, 1), + "ete": (7, 1), + "automne": (10, 1), + "hiver": (1, 15), + "rentree": (9, 1), +} + +_LITTERALE = re.compile( + r"\b(\d{1,2})\s*(?:er|ᵉʳ|re|ère)?\.?\s+" + r"(janvier|janv|fevrier|fevr|fev|mars|avril|avr|mai|juin|juillet|juill|juil|" + r"aout|septembre|sept|octobre|oct|novembre|nov|decembre|dec)\.?\s*(\d{4})?", + re.IGNORECASE, +) +_NUMERIQUE = re.compile(r"\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b") +_ISO = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b") +_MOIS_SEUL = re.compile( + r"\b(debut|mi|fin|courant)?\s*-?\s*" + r"(janvier|janv|fevrier|fevr|fev|mars|avril|avr|mai|juin|juillet|juill|juil|" + r"aout|septembre|sept|octobre|oct|novembre|nov|decembre|dec)\.?" + r"\s+(\d{4})\b", + re.IGNORECASE, +) +_SAISON = re.compile( + r"\b(printemps|ete|automne|hiver|rentree)\s+(?:de\s+)?(\d{4})\b", re.IGNORECASE +) + + +def sans_accents(texte: str) -> str: + """Retire les diacritiques : « août » → « aout », « févr. » → « fevr. ».""" + decompose = unicodedata.normalize("NFD", texte) + return "".join(c for c in decompose if unicodedata.category(c) != "Mn") + + +def lire_date(fragment: str, *, annee_defaut: int | None = None) -> date | None: + """Extrait la première date **exacte** d'un fragment, sinon `None`. + + Une date exacte suppose un jour identifié. « Fin août 2026 » ou + « Automne 2026 » renvoient `None` : voir `lire_echeance`. + """ + if not fragment: + return None + plat = sans_accents(fragment).lower() + + if m := _ISO.search(plat): + return _construire(int(m.group(1)), int(m.group(2)), int(m.group(3))) + + if m := _NUMERIQUE.search(plat): + return _construire(int(m.group(3)), int(m.group(2)), int(m.group(1))) + + if m := _LITTERALE.search(plat): + jour = int(m.group(1)) + mois = MOIS[m.group(2)] + annee = int(m.group(3)) if m.group(3) else annee_defaut + if annee is None: + return None + return _construire(annee, mois, jour) + + return None + + +def lire_echeance(fragment: str) -> tuple[date | None, str]: + """Interprète une échéance prévisionnelle. + + Retourne `(date approchée ou None, libellé nettoyé)`. Le libellé est + toujours conservé : c'est lui qui est affiché, la date ne sert qu'au tri et + au filtrage par période. + """ + libelle = " ".join(fragment.split()).strip(" .;") + if not libelle: + return None, "" + + if exacte := lire_date(libelle): + return exacte, libelle + + plat = sans_accents(libelle).lower() + + if m := _MOIS_SEUL.search(plat): + qualificatif, mois, annee = m.group(1), MOIS[m.group(2)], int(m.group(3)) + # Un mois sans jour est situé conventionnellement : début = 5, fin = 25, + # milieu ou absence de qualificatif = 15. La date sert au tri, jamais à + # affirmer un fait — c'est le libellé qui est affiché. + jour = {"debut": 5, "fin": 25}.get(qualificatif or "", 15) + return _construire(annee, mois, jour), libelle + + if m := _SAISON.search(plat): + mois, jour = SAISONS[m.group(1)] + return _construire(int(m.group(2)), mois, jour), libelle + + # « Indéterminée », « Sans échéance », « Incertaine »… : aucune date. + return None, libelle + + +def _construire(annee: int, mois: int, jour: int) -> date | None: + try: + return date(annee, mois, jour) + except ValueError: + return None + + +def toutes_les_dates(fragment: str, *, annee_defaut: int | None = None) -> list[date]: + """Toutes les dates exactes d'un fragment, dans l'ordre d'apparition. + + Utile pour les cellules qui en contiennent plusieurs, par exemple + « Adoptée (20-21 juill.) ; saisine du 24 juill. ». + """ + plat = sans_accents(fragment).lower() + trouvees: list[tuple[int, date]] = [] + + for motif, ordre in ((_ISO, "ymd"), (_NUMERIQUE, "dmy"), (_LITTERALE, "litterale")): + for m in motif.finditer(plat): + if ordre == "ymd": + d = _construire(int(m.group(1)), int(m.group(2)), int(m.group(3))) + elif ordre == "dmy": + d = _construire(int(m.group(3)), int(m.group(2)), int(m.group(1))) + else: + annee = int(m.group(3)) if m.group(3) else annee_defaut + d = _construire(annee, MOIS[m.group(2)], int(m.group(1))) if annee else None + if d: + trouvees.append((m.start(), d)) + + return [d for _, d in sorted(trouvees)] diff --git a/pipeline/parseurs/markdown.py b/pipeline/parseurs/markdown.py new file mode 100644 index 0000000..182e261 --- /dev/null +++ b/pipeline/parseurs/markdown.py @@ -0,0 +1,180 @@ +"""Briques de lecture du markdown du corpus. + +Trois besoins récurrents : découper les tableaux, retirer le balisage d'emphase +sans perdre le texte, et récolter les marqueurs de citation `[^dim07-24^]` qui +relient un fait de chapitre à sa source dans un rapport de dimension. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^] +MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]") + +_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL) +_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +_CODE = re.compile(r"`([^`]+)`") +_SEPARATEUR_TABLEAU = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") + + +@dataclass(slots=True) +class Tableau: + """Un tableau markdown, avec le titre de section qui le précède.""" + + entetes: list[str] + lignes: list[dict[str, str]] + section: str = "" + ligne_debut: int = 0 + + def __len__(self) -> int: + return len(self.lignes) + + def colonne(self, prefixe: str) -> str | None: + """Retrouve un nom d'en-tête par son début, insensible à la casse.""" + prefixe = prefixe.lower() + for entete in self.entetes: + if entete.lower().startswith(prefixe): + return entete + return None + + +@dataclass(slots=True) +class Marqueur: + """Une référence `[^dimXX-N^]` rencontrée dans un texte.""" + + brut: str # « dim07-24 » + dimension: str # « dim07 », « phase5 », « insight », ou « ref » + numero: str # « 24 », « 3.1 » + + @property + def cle(self) -> str: + return f"{self.dimension}-{self.numero}" if self.numero else self.dimension + + +def extraire_tableaux(markdown: str) -> list[Tableau]: + """Découpe tous les tableaux d'un document markdown. + + Une ligne d'en-tête suivie d'une ligne de séparation ouvre un tableau ; la + première ligne qui ne commence pas par `|` le referme. + """ + lignes = markdown.splitlines() + tableaux: list[Tableau] = [] + section = "" + i = 0 + + while i < len(lignes): + ligne = lignes[i] + + if ligne.lstrip().startswith("#"): + section = ligne.lstrip("#").strip() + i += 1 + continue + + est_ligne_tableau = ligne.strip().startswith("|") + separateur_suivant = i + 1 < len(lignes) and _SEPARATEUR_TABLEAU.match(lignes[i + 1]) + + if est_ligne_tableau and separateur_suivant: + entetes = _decouper_ligne(ligne) + corps: list[dict[str, str]] = [] + j = i + 2 + while j < len(lignes) and lignes[j].strip().startswith("|"): + cellules = _decouper_ligne(lignes[j]) + if any(c for c in cellules): + # Tolère les lignes plus courtes ou plus longues que l'en-tête. + cellules += [""] * (len(entetes) - len(cellules)) + corps.append(dict(zip(entetes, cellules[: len(entetes)], strict=True))) + j += 1 + tableaux.append( + Tableau(entetes=entetes, lignes=corps, section=section, ligne_debut=i + 1) + ) + i = j + continue + + i += 1 + + return tableaux + + +def _decouper_ligne(ligne: str) -> list[str]: + """Découpe une ligne de tableau en cellules, en respectant les `\\|` échappés.""" + interne = ligne.strip() + if interne.startswith("|"): + interne = interne[1:] + if interne.endswith("|"): + interne = interne[:-1] + cellules = re.split(r"(? str: + """Retire le balisage d'emphase, les liens et les marqueurs de citation.""" + resultat = _CODE.sub(r"\1", texte) + resultat = _LIEN_MD.sub(r"\1", resultat) + resultat = _EMPHASE.sub(r"\1", resultat) + if not garder_marqueurs: + resultat = MOTIF_MARQUEUR.sub("", resultat) + return " ".join(resultat.split()).strip(" ;,") + + +def extraire_marqueurs(texte: str) -> list[Marqueur]: + """Récolte les marqueurs de citation d'un fragment, sans doublon.""" + marqueurs: list[Marqueur] = [] + vus: set[str] = set() + + for brut in MOTIF_MARQUEUR.findall(texte): + if brut in vus: + continue + vus.add(brut) + if "-" in brut: + dimension, numero = brut.split("-", 1) + elif brut.isdigit(): + # Marqueur local à un fichier de dimension : [^24^] + dimension, numero = "", brut + else: + dimension, numero = brut, "" + marqueurs.append(Marqueur(brut=brut, dimension=dimension, numero=numero)) + + return marqueurs + + +@dataclass(slots=True) +class Section: + """Un bloc de texte délimité par un titre markdown.""" + + niveau: int + titre: str + corps: str + ligne_debut: int + sous_sections: list[Section] = field(default_factory=list) + + +def decouper_sections(markdown: str, *, niveau_max: int = 3) -> list[Section]: + """Découpe un document en sections à plat, jusqu'au niveau de titre demandé.""" + lignes = markdown.splitlines() + sections: list[Section] = [] + courante: Section | None = None + tampon: list[str] = [] + + for numero, ligne in enumerate(lignes, start=1): + entete = re.match(r"^(#{1,6})\s+(.*)$", ligne) + if entete and len(entete.group(1)) <= niveau_max: + if courante: + courante.corps = "\n".join(tampon).strip() + sections.append(courante) + courante = Section( + niveau=len(entete.group(1)), + titre=entete.group(2).strip(), + corps="", + ligne_debut=numero, + ) + tampon = [] + else: + tampon.append(ligne) + + if courante: + courante.corps = "\n".join(tampon).strip() + sections.append(courante) + + return sections diff --git a/pipeline/parseurs/tableaux_sec10.py b/pipeline/parseurs/tableaux_sec10.py new file mode 100644 index 0000000..1de3df7 --- /dev/null +++ b/pipeline/parseurs/tableaux_sec10.py @@ -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"(? 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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a454ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +"""Fixtures partagées. + +Le corpus de `data/input/` est lu tel quel : c'est la seule façon de garantir +que les parseurs suivent le format réel, et non une idée qu'on s'en fait. Les +tests ne l'écrivent jamais. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator + +import pytest + +from pipeline import chemins, db + + +@pytest.fixture(scope="session") +def annexe() -> str: + """Contenu de `sec10.md`, l'annexe récapitulative.""" + return chemins.chapitre(10).read_text(encoding="utf-8") + + +@pytest.fixture(scope="session") +def textes_annexe(): + """Textes extraits de l'annexe, analysés une seule fois pour la session.""" + from pipeline.parseurs.tableaux_sec10 import parser + + return parser(chemins.chapitre(10).read_text(encoding="utf-8")) + + +@pytest.fixture(scope="session") +def par_identifiant(textes_annexe) -> dict: + return {texte.id: texte for texte in textes_annexe.textes} + + +@pytest.fixture +def base(tmp_path) -> Iterator[sqlite3.Connection]: + """Base SQLite éphémère, migrée, propre à chaque test.""" + cx = db.initialiser(tmp_path / "test.db") + try: + yield cx + finally: + cx.close() + + +@pytest.fixture +def base_ensemencee(base, textes_annexe) -> sqlite3.Connection: + """Base contenant les textes de l'annexe.""" + db.enregistrer_textes(base, textes_annexe.textes) + return base diff --git a/tests/test_dates_fr.py b/tests/test_dates_fr.py new file mode 100644 index 0000000..227fb03 --- /dev/null +++ b/tests/test_dates_fr.py @@ -0,0 +1,81 @@ +"""Lecture des dates françaises du corpus.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from pipeline.parseurs.dates_fr import lire_date, lire_echeance, sans_accents, toutes_les_dates + + +@pytest.mark.parametrize( + ("fragment", "attendu"), + [ + # Formes réelles de la colonne « Promulgation / JO » du tableau A. + ("23 avr. 2026 (JO 24 avr.)", date(2026, 4, 23)), + ("29 avr. 2026 (JO 30 avr.)", date(2026, 4, 29)), + ("9 mai 2026 (JO 10 mai)", date(2026, 5, 9)), + ("12 juin 2026 (JO 13 juin)", date(2026, 6, 12)), + ("30 juin 2026 (JO 1er juill.)", date(2026, 6, 30)), + ("13 juill. 2026 (JO 14 juill.)", date(2026, 7, 13)), + ("1er juill. 2026", date(2026, 7, 1)), + # Formes numériques du fichier de validation de phase 5. + ("24.07.2026", date(2026, 7, 24)), + ("22/07/2026", date(2026, 7, 22)), + ("2026-07-25", date(2026, 7, 25)), + ], +) +def test_lire_date_formes_du_corpus(fragment: str, attendu: date) -> None: + assert lire_date(fragment) == attendu + + +def test_lire_date_sans_annee_utilise_le_defaut() -> None: + assert lire_date("6 juill.", annee_defaut=2026) == date(2026, 7, 6) + assert lire_date("6 juill.") is None + + +def test_lire_date_ignore_les_fragments_sans_jour() -> None: + assert lire_date("Fin août 2026") is None + assert lire_date("Automne 2026") is None + + +def test_toutes_les_dates_conserve_l_ordre() -> None: + assert toutes_les_dates("23 avr. 2026 (JO 24 avr.)", annee_defaut=2026) == [ + date(2026, 4, 23), + date(2026, 4, 24), + ] + + +@pytest.mark.parametrize( + ("fragment", "attendu"), + [ + ("~24 août 2026", date(2026, 8, 24)), + ("Début août 2026", date(2026, 8, 5)), + ("Fin août 2026", date(2026, 8, 25)), + ("Oct. 2026 ; adoption visée fin 2026", date(2026, 10, 15)), + ("Automne 2026", date(2026, 10, 1)), + ("Rentrée 2026", date(2026, 9, 1)), + ], +) +def test_lire_echeance_situe_les_previsions(fragment: str, attendu: date) -> None: + approchee, libelle = lire_echeance(fragment) + assert approchee == attendu + # Le libellé d'origine est toujours conservé : c'est lui qui est affiché. + assert libelle == fragment + + +@pytest.mark.parametrize( + "fragment", + ["Indéterminée", "Sans échéance", "Sans date fixée", "Session ordinaire 2026-2027"], +) +def test_lire_echeance_refuse_d_inventer(fragment: str) -> None: + approchee, libelle = lire_echeance(fragment) + assert approchee is None + assert libelle == fragment + + +def test_sans_accents() -> None: + assert sans_accents("août") == "aout" + assert sans_accents("Échéance probable") == "Echeance probable" + assert sans_accents("chlordécone") == "chlordecone" diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000..c8a3a65 --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,83 @@ +"""Découpage des tableaux, nettoyage et marqueurs de citation.""" + +from __future__ import annotations + +from pipeline.parseurs.markdown import ( + decouper_sections, + extraire_marqueurs, + extraire_tableaux, + nettoyer, +) + +TABLEAU = """\ +## Tableau d'essai + +| Col A | Col B | +|---|---| +| a1 | b1 | +| a2 | b2 | + +Texte après le tableau. +""" + + +def test_extraire_tableaux_simple() -> None: + (tableau,) = extraire_tableaux(TABLEAU) + assert tableau.entetes == ["Col A", "Col B"] + assert tableau.lignes == [{"Col A": "a1", "Col B": "b1"}, {"Col A": "a2", "Col B": "b2"}] + assert tableau.section == "Tableau d'essai" + assert len(tableau) == 2 + + +def test_extraire_tableaux_tolere_une_ligne_courte() -> None: + source = "| A | B | C |\n|---|---|---|\n| 1 | 2 |\n" + (tableau,) = extraire_tableaux(source) + assert tableau.lignes == [{"A": "1", "B": "2", "C": ""}] + + +def test_extraire_tableaux_respecte_les_barres_echappees() -> None: + source = "| A | B |\n|---|---|\n| x \\| y | z |\n" + (tableau,) = extraire_tableaux(source) + assert tableau.lignes[0]["A"] == "x | y" + + +def test_extraire_tableaux_sur_le_corpus_reel(annexe: str) -> None: + tableaux = extraire_tableaux(annexe) + assert len(tableaux) == 2 + tableau_a, tableau_b = tableaux + assert tableau_a.entetes[0] == "N° de loi" + assert len(tableau_a) == 27, "27 lois promulguées attendues au tableau A" + assert tableau_b.entetes[0] == "Texte" + assert len(tableau_b) == 23 + + +def test_nettoyer_retire_emphase_et_marqueurs() -> None: + brut = "**Adoptée, non promulguée** (21 juill.) ; saisine [^dim04-487^][^phase5-3.1^]" + assert nettoyer(brut) == "Adoptée, non promulguée (21 juill.) ; saisine" + + +def test_nettoyer_peut_garder_les_marqueurs() -> None: + brut = "Chlordécone [^dim07-24^]" + assert nettoyer(brut, garder_marqueurs=True) == "Chlordécone [^dim07-24^]" + + +def test_extraire_marqueurs_distingue_les_dimensions() -> None: + marqueurs = extraire_marqueurs("[^dim07-24^] et [^phase5-3.1^] et [^insight-5^] et [^42^]") + assert [(m.dimension, m.numero) for m in marqueurs] == [ + ("dim07", "24"), + ("phase5", "3.1"), + ("insight", "5"), + ("", "42"), + ] + + +def test_extraire_marqueurs_dedoublonne() -> None: + assert len(extraire_marqueurs("[^dim01-3^] [^dim01-3^] [^dim01-4^]")) == 2 + + +def test_decouper_sections() -> None: + source = "# Titre\ncorps un\n\n## Sous\ncorps deux\n" + sections = decouper_sections(source) + assert [s.titre for s in sections] == ["Titre", "Sous"] + assert sections[0].corps == "corps un" + assert sections[1].niveau == 2 diff --git a/tests/test_tableaux_sec10.py b/tests/test_tableaux_sec10.py new file mode 100644 index 0000000..8404ddb --- /dev/null +++ b/tests/test_tableaux_sec10.py @@ -0,0 +1,305 @@ +"""Parseur de l'annexe récapitulative. + +Ces tests portent sur le corpus réel : ils échouent si le format d'entrée +change, ce qui est le comportement voulu — un seed silencieusement incomplet +serait pire qu'une erreur bruyante. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from pipeline.modeles import ( + Pertinence, + RegimeLibertes, + ResultatCC, + Statut, + Theme, + TypeTexte, +) +from pipeline.parseurs.tableaux_sec10 import ( + _deduire_themes, + _lire_pertinence, + _lire_regime, + _lire_statut, + extraire_marqueurs_annexe, +) + +# ───────────────────────────────────────────────────────────────────────────── +# Volumétrie +# ───────────────────────────────────────────────────────────────────────────── + + +def test_aucun_avertissement(textes_annexe) -> None: + assert textes_annexe.avertissements == [] + + +def test_volumetrie_minimale(textes_annexe) -> None: + # 27 lois promulguées + 23 lignes du tableau B, dont 2 éclatées en deux + # textes chacune : 27 + 21 + 4 = 52. + assert len(textes_annexe) == 52 + assert len(textes_annexe) >= 49, "le cahier des charges exige au moins 49 textes" + + +def test_identifiants_uniques(textes_annexe) -> None: + identifiants = [t.id for t in textes_annexe.textes] + assert len(identifiants) == len(set(identifiants)) + + +def test_toutes_les_lois_promulguees_ont_numero_et_date(textes_annexe) -> None: + promulguees = [t for t in textes_annexe.textes if t.statut is Statut.PROMULGUEE] + assert len(promulguees) == 27 + for texte in promulguees: + assert texte.numero_officiel, texte.id + assert texte.date_promulgation, texte.id + assert texte.regime_libertes is not None, texte.id + assert texte.guadeloupe_pertinence is not None, texte.id + + +def test_aucun_texte_non_promulgue_ne_porte_de_numero(textes_annexe) -> None: + for texte in textes_annexe.textes: + if texte.statut is not Statut.PROMULGUEE: + assert texte.numero_officiel is None, texte.id + + +# ───────────────────────────────────────────────────────────────────────────── +# Contrôles d'acceptation du §7 du cahier des charges +# ───────────────────────────────────────────────────────────────────────────── + + +def test_loi_chlordecone(par_identifiant) -> None: + texte = par_identifiant["loi-2026-491"] + assert texte.numero_officiel == "2026-491" + assert texte.statut is Statut.PROMULGUEE + assert texte.date_promulgation == date(2026, 6, 12) + assert texte.guadeloupe_pertinence is Pertinence.FORTE + assert {Theme.SANTE, Theme.OUTRE_MER, Theme.MEMOIRE_PATRIMOINE} <= set(texte.themes) + + +def test_loi_fraudes_et_sa_decision_cc(par_identifiant) -> None: + texte = par_identifiant["loi-2026-534"] + assert texte.statut is Statut.PROMULGUEE + assert texte.date_promulgation == date(2026, 6, 25) + assert [d.numero_affaire for d in texte.decisions_cc] == ["2026-904 DC"] + assert texte.guadeloupe_pertinence is Pertinence.FORTE + + +def test_loi_riposte_statut_et_evenement_de_saisine(par_identifiant) -> None: + """Le §7 impose le statut `adoptee_non_promulguee` ET l'événement de saisine.""" + texte = par_identifiant["loi-riposte"] + assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE + assert texte.numero_officiel is None + + saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"] + assert len(saisines) == 1 + assert "2026-915 DC" in saisines[0].description + assert "24 juillet 2026" in saisines[0].description + assert saisines[0].date_evenement == date(2026, 7, 24) + + (affaire,) = texte.decisions_cc + assert affaire.numero_affaire == "2026-915 DC" + assert affaire.resultat is ResultatCC.EN_INSTANCE + assert affaire.date_saisine == date(2026, 7, 24) + + +def test_ppl_aide_a_mourir(par_identifiant) -> None: + texte = par_identifiant["ppl-aide-a-mourir"] + assert texte.type is TypeTexte.PPL + assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE + assert texte.date_adoption == date(2026, 7, 15) + (affaire,) = texte.decisions_cc + assert affaire.numero_affaire == "2026-910 DC" + # Le corpus ne date pas cette saisine : rien ne doit être inventé. + assert affaire.date_saisine is None + assert affaire.date_decision_attendue == date(2026, 8, 20) + + +def test_pjl_pacte_migration(par_identifiant) -> None: + texte = par_identifiant["pjl-pacte-migration"] + assert texte.type is TypeTexte.PJL + assert texte.statut is Statut.NAVETTE + assert Theme.MIGRATION in texte.themes + + +def test_loi_simplification_economique(par_identifiant) -> None: + texte = par_identifiant["loi-2026-403"] + assert texte.statut is Statut.PROMULGUEE + (affaire,) = texte.decisions_cc + assert affaire.numero_affaire == "2026-903 DC" + assert affaire.resultat is ResultatCC.NON_CONFORMITE_PARTIELLE + assert "25 articles censurés" in (affaire.resume or "") + assert texte.guadeloupe_pertinence is Pertinence.FORTE + + +def test_loi_philippine_est_validee_sans_fausse_saisine(par_identifiant) -> None: + """Sa cellule cite une décision, pas une saisine : aucun événement de saisine.""" + texte = par_identifiant["loi-philippine"] + assert texte.statut is Statut.VALIDEE_CC + assert [e.type_etape for e in texte.evenements if e.type_etape == "saisine_cc"] == [] + decisions = [e for e in texte.evenements if e.type_etape == "decision_cc"] + assert decisions and decisions[0].date_evenement == date(2026, 7, 23) + + +def test_programmation_militaire_saisie_sans_numero_d_affaire(par_identifiant) -> None: + """« Conseil constitutionnel saisi le 6 juill. » : événement, pas d'affaire.""" + texte = par_identifiant["loi-programmation-militaire"] + saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"] + assert len(saisines) == 1 + assert saisines[0].date_evenement == date(2026, 7, 6) + assert texte.decisions_cc == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Éclatement des lignes doubles +# ───────────────────────────────────────────────────────────────────────────── + + +def test_accords_bilateraux_eclates(par_identifiant) -> None: + kazakhstan = par_identifiant["accord-kazakhstan-readmission"] + colombie = par_identifiant["accord-colombie-extradition"] + assert kazakhstan.type is TypeTexte.ACCORD_INTERNATIONAL + assert kazakhstan.date_adoption == date(2026, 1, 28) + assert colombie.date_adoption == date(2026, 2, 18) + + +def test_macf_eclate_en_deux_statuts(par_identifiant) -> None: + """La ligne MACF décrit deux textes de statuts différents.""" + ratification = par_identifiant["pjl-ratification-ordonnance-macf"] + accord = par_identifiant["accord-macf-plateforme-centrale"] + assert ratification.statut is Statut.DEPOSEE_NON_EXAMINEE + assert accord.statut is Statut.ANNONCEE + + +# ───────────────────────────────────────────────────────────────────────────── +# Lecture de cellules +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("cellule", "attendu"), + [ + ("**Droits+** : contrôle externe de la détention", RegimeLibertes.DROITS_PLUS), + ("**Contrôle+** : durées réduites", RegimeLibertes.CONTROLE_PLUS), + ("Mixte : anti-impayés ; vigilance débiteur", RegimeLibertes.MIXTE), + ("Neutre", RegimeLibertes.NEUTRE), + ("Neutre à **droits+** : équité territoriale", RegimeLibertes.DROITS_PLUS), + ], +) +def test_lire_regime(cellule: str, attendu: RegimeLibertes) -> None: + regime, note = _lire_regime(cellule) + assert regime is attendu + assert note + + +@pytest.mark.parametrize( + ("cellule", "attendu", "note"), + [ + ("Forte — maison d'arrêt de Baie-Mahault", Pertinence.FORTE, "maison d'arrêt"), + ("Moyenne — CPCE applicable aux DROM", Pertinence.MOYENNE, "CPCE"), + ("Faible — sans objet", Pertinence.FAIBLE, "sans objet"), + ], +) +def test_lire_pertinence(cellule: str, attendu: Pertinence, note: str) -> None: + pertinence, motif = _lire_pertinence(cellule) + assert pertinence is attendu + assert note in (motif or "") + + +@pytest.mark.parametrize( + ("cellule", "attendu"), + [ + ("**Adoptée, non promulguée** (21 juill., CMP)", Statut.ADOPTEE_NON_PROMULGUEE), + ("**En navette** : Sénat 1re lecture 20 mai", Statut.NAVETTE), + ("**Déposé, non examiné** : Sénat n° 890", Statut.DEPOSEE_NON_EXAMINEE), + ("**Annoncé** : transmis au Conseil d'État", Statut.ANNONCEE), + ("**Adoptée par une chambre** : Sénat 5-6 mai", Statut.NAVETTE), + ("**Adoptée en 1re lecture** : AN 28 mai", Statut.NAVETTE), + ("**Adoptée** (15-16 juin) ; conforme avec réserves ; **promulgation pendante**", + Statut.VALIDEE_CC), + ], +) +def test_lire_statut(cellule: str, attendu: Statut) -> None: + assert _lire_statut(cellule) is attendu + + +def test_lire_statut_refuse_l_inconnu() -> None: + with pytest.raises(ValueError, match="statut non reconnu"): + _lire_statut("Texte au statut fantaisiste") + + +def test_themes_ne_confondent_pas_les_sous_chaines() -> None: + """« eau » ne doit pas se déclencher sur « réseaux », « drapeau », « Retailleau ».""" + assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL réseaux sociaux et mineurs") + assert Theme.ENVIRONNEMENT not in _deduire_themes("outrage à l'hymne et au drapeau") + assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL entrisme Retailleau") + # Contrôle positif : le mot isolé compte bien. + assert Theme.ENVIRONNEMENT in _deduire_themes("habilitation Martinique énergie et eau") + + +def test_themes_plafonnes_a_quatre() -> None: + long_texte = " ".join( + ["justice", "sécurité", "numérique", "social", "fiscal", "agriculture", "santé"] + ) + assert len(_deduire_themes(long_texte)) <= 4 + + +# ───────────────────────────────────────────────────────────────────────────── +# Marqueurs de citation +# ───────────────────────────────────────────────────────────────────────────── + + +def test_marqueurs_de_l_annexe(annexe: str) -> None: + associations = extraire_marqueurs_annexe(annexe) + assert "2026-491" in associations + assert "dim07-24" in associations["2026-491"] + riposte = next(cle for cle in associations if cle.startswith("Loi « Riposte »")) + assert "phase5-3.1" in associations[riposte] + + +# ───────────────────────────────────────────────────────────────────────────── +# Écriture en base +# ───────────────────────────────────────────────────────────────────────────── + + +def test_seed_en_base(base_ensemencee) -> None: + cx = base_ensemencee + assert cx.execute("SELECT COUNT(*) FROM textes").fetchone()[0] == 52 + assert cx.execute("SELECT COUNT(*) FROM decisions_cc").fetchone()[0] == 12 + + +def test_facette_devant_cc(base_ensemencee) -> None: + """Le §8.7 attend au moins 7 textes suspendus au Conseil constitutionnel.""" + total = base_ensemencee.execute( + "SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1" + ).fetchone()[0] + assert total >= 7 + + +def test_les_lois_promulguees_ne_sont_pas_devant_le_cc(base_ensemencee) -> None: + """Une décision rendue mais non datée ne doit pas passer pour une instance.""" + restantes = base_ensemencee.execute( + "SELECT id FROM v_textes WHERE devant_cc = 1 AND statut = 'promulguee'" + ).fetchall() + assert restantes == [] + + +def test_recherche_plein_texte(base_ensemencee) -> None: + """La recherche doit trouver « chlordécone » sans les accents.""" + lignes = base_ensemencee.execute( + "SELECT t.id FROM textes_fts f JOIN textes t ON t.rowid = f.rowid " + "WHERE textes_fts MATCH 'chlordecone'" + ).fetchall() + assert [ligne["id"] for ligne in lignes] == ["loi-2026-491"] + + +def test_ecriture_idempotente(base, textes_annexe) -> None: + """Rejouer le seed ne duplique ni textes, ni sources, ni événements.""" + from pipeline import db + + db.enregistrer_textes(base, textes_annexe.textes) + premier = db.statistiques(base) + db.enregistrer_textes(base, textes_annexe.textes) + second = db.statistiques(base) + assert premier == second