Phase 2 : parseur de l'annexe sec10 — 52 textes, 12 affaires CC

Briques réutilisables :
- dates_fr : lit les formes réelles du corpus (« 23 avr. 2026 (JO 24 avr.) »,
  « 1er juill. », « 24.07.2026 ») et distingue une date exacte d'une échéance
  prévisionnelle (« Fin août 2026 » → situé au 25, libellé conservé, jamais
  présenté comme un fait)
- markdown : découpage des tableaux (barres échappées, lignes courtes),
  nettoyage, extraction des marqueurs [^dim07-24^] / [^phase5-3.1^]

Parseur sec10 :
- 27 lois promulguées + 21 lignes simples + 2 lignes éclatées en 4 textes = 52
- éclatement justifié : les lignes « accords Kazakhstan et Colombie » et
  « MACF » décrivent chacune deux textes de statuts différents
- migration 002 : regime_libertes stocké à part, faute de savoir quel public
  la cotation globale du rapport vise — dérouler « droits+ » sur les trois
  publics aurait inventé une ventilation absente de la source
- migration 003 : la facette « devant le CC » couvre aussi les saisines sans
  numéro d'affaire (programmation militaire), sinon 6 textes au lieu de 7

Trois pièges du corpus, corrigés et couverts par des tests :
- « eau » matchait comme sous-chaîne dans réseaux / drapeau / Retailleau
- une décision citée sans saisine créait une fausse saisine (loi Philippine)
- la date de saisine était pêchée dans la colonne « échéance », datant la
  saisine de l'aide à mourir du mois suivant

72 tests, 90 % de couverture sur pipeline/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyber Mawonaj
2026-07-25 18:22:18 -04:00
co-authored by Claude Opus 5
parent 31d3935bce
commit c36b8dc631
12 changed files with 1747 additions and 1 deletions
+2 -1
View File
@@ -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",
)
@@ -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;
+43
View File
@@ -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;
+14
View File
@@ -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
),
+5
View File
@@ -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.
"""
+154
View File
@@ -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)]
+180
View File
@@ -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"(?<!\\)\|", interne)
return [c.replace(r"\|", "|").strip() for c in cellules]
def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> 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
+797
View File
@@ -0,0 +1,797 @@
"""Parseur de l'annexe récapitulative (`lois-france-2026-guadeloupe_sec10.md`).
C'est le chemin le plus fiable du seed : deux tableaux tenus à jour à la main
par la recherche, avec numéros de loi, dates de promulgation, statuts au
25 juillet 2026, cotation des libertés et pertinence Guadeloupe.
- Tableau A : 27 lois promulguées du 23 avril au 23 juillet 2026.
- Tableau B : 23 lignes de textes adoptés non promulgués, en navette, déposés
ou annoncés. Deux de ces lignes portent chacune deux textes distincts, avec
des statuts différents : elles sont éclatées (voir `LIGNES_A_ECLATER`).
Le parseur ne produit que ce que les cellules disent. Tout ce qui manque —
résumé, points clés, impacts par public — est rempli plus loin dans le seed, à
partir des chapitres et des rapports de dimension.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from pipeline.journal import logger
from pipeline.modeles import (
Confiance,
DecisionCC,
Evenement,
Pertinence,
RegimeLibertes,
ResultatCC,
Statut,
Texte,
Theme,
TypeEtape,
TypeTexte,
)
from pipeline.parseurs.dates_fr import lire_date, lire_echeance, sans_accents, toutes_les_dates
from pipeline.parseurs.markdown import Tableau, extraire_marqueurs, extraire_tableaux, nettoyer
log = logger("parseur.sec10")
ANNEE_CORPUS = 2026
MOTIF_AFFAIRE = re.compile(r"\b(\d{4}-\d{1,4})\s*DC\b")
MOTIF_NUMERO_LOI = re.compile(r"^(LO\s+)?(\d{4}-\d{1,4})$", re.IGNORECASE)
# ─────────────────────────────────────────────────────────────────────────────
# Correspondances déclaratives
#
# Ces tables ne sont pas des données : ce sont des identifiants et des
# rattachements. Chaque entrée est justifiée par la ligne du corpus qu'elle
# désigne, citée en commentaire.
# ─────────────────────────────────────────────────────────────────────────────
# Identifiants canoniques du tableau B, indexés par le début de l'intitulé
# (comparaison sans accent ni casse). Les trois premiers sont imposés par le §7
# du cahier des charges ; les autres suivent la même logique de lisibilité.
SLUGS_TABLEAU_B: dict[str, str] = {
"loi « riposte »": "loi-riposte",
"ppl relative au droit a l'aide a mourir": "ppl-aide-a-mourir",
"pjl d'habilitation": "pjl-pacte-migration",
"loi « philippine »": "loi-philippine",
"ppl reseaux sociaux et mineurs": "ppl-reseaux-sociaux-mineurs",
"loi d'urgence pour la protection et la souverainete agricoles": "loi-urgence-agricole",
"loi actualisant la programmation militaire": "loi-programmation-militaire",
"ppl « pour une montagne vivante": "ppl-montagne",
"ppl relative au patrimoine immobilier": "ppl-patrimoine-immobilier-etat",
"pjl relatif a la protection des enfants": "pjl-protection-enfants",
"pjl cohesion republicaine": "pjl-cohesion-republicaine",
"pjl logement": "pjl-logement",
"accord globe": "accord-globe",
"ppl « entrisme »": "ppl-entrisme",
"pjl « separatisme »": "pjl-separatisme",
"ppl « outrage a l'hymne": "ppl-outrage-hymne-drapeau",
"ppl d'abrogation du « code noir »": "ppl-abrogation-code-noir",
"ppl « adaptation du droit des outre-mer »": "ppl-adaptation-droit-outre-mer",
"ppl « dispositif ultramarin d'emploi durable »": "ppl-emploi-ultramarin",
"ppl « entites naturelles": "ppl-entites-naturelles",
"ppl visant a mettre fin au devoir conjugal": "ppl-devoir-conjugal",
}
@dataclass(slots=True)
class Eclatement:
"""Découpe une ligne du tableau B qui décrit deux textes distincts."""
slug: str
titre: str
type: TypeTexte
statut: Statut
# Fragment de la cellule « Statut » qui concerne ce texte-là, pour que la
# datation ne mélange pas les deux parcours.
fragment_statut: str
note: str
# Deux lignes du tableau B portent deux textes chacune, avec des statuts
# différents. Les laisser fusionnées reviendrait à attribuer un statut unique à
# deux parcours parlementaires distincts.
LIGNES_A_ECLATER: dict[str, list[Eclatement]] = {
# « Accords Kazakhstan (réadmission, AN n° 2416) et Colombie (extradition,
# AN n° 2509) | Adoptés par le Sénat (28 janv. et 18 févr. 2026) ; en
# attente à l'Assemblée »
"accords kazakhstan": [
Eclatement(
slug="accord-kazakhstan-readmission",
titre="Accord France-Kazakhstan de réadmission (AN n° 2416)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.NAVETTE,
fragment_statut="Adopté par le Sénat le 28 janv. 2026 ; en attente à l'Assemblée",
note="Réadmission vers des « hubs » tiers (clause de non-refoulement)",
),
Eclatement(
slug="accord-colombie-extradition",
titre="Accord France-Colombie d'extradition (AN n° 2509)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.NAVETTE,
fragment_statut="Adopté par le Sénat le 18 févr. 2026 ; en attente à l'Assemblée",
note="Extradition",
),
],
# « MACF : ratification de l'ordonnance n° 2026-326 et accord « plateforme
# centrale commune » | Ratification déposée au Sénat, non examinée
# (~10 juill.) ; accord plateforme en attente de dépôt »
"macf : ratification": [
Eclatement(
slug="pjl-ratification-ordonnance-macf",
titre="Ratification de l'ordonnance n° 2026-326 (mécanisme d'ajustement "
"carbone aux frontières)",
type=TypeTexte.PJL,
statut=Statut.DEPOSEE_NON_EXAMINEE,
fragment_statut="Déposée au Sénat, non examinée (~10 juill. 2026)",
note="Obligations déclaratives des importateurs ; négoces guadeloupéens "
"de matériaux tiers concernés",
),
Eclatement(
slug="accord-macf-plateforme-centrale",
titre="Accord « plateforme centrale commune » (MACF)",
type=TypeTexte.ACCORD_INTERNATIONAL,
statut=Statut.ANNONCEE,
fragment_statut="En attente de dépôt",
note="Obligations déclaratives des importateurs",
),
],
}
# Rattachement des décisions du Conseil constitutionnel rendues sur les lois du
# tableau A. Les numéros figurent dans la colonne « Statut libertés », le
# résultat dans `cross_verification.md` §1.
RESULTATS_CC_CONNUS: dict[str, tuple[ResultatCC, str]] = {
"2026-903 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"25 articles censurés (ZFE art. 37, dérogations ZAN art. 35 §IV)",
),
"2026-904 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"Articles 10, 12 (numéro d'identification des associations) et 32 censurés "
"comme cavaliers ; 6 réserves",
),
"2026-905 DC": (ResultatCC.CONFORME, "Conforme"),
"2026-906 DC": (
ResultatCC.CONFORME_AVEC_RESERVES,
"Conforme avec réserves (décision du 23 juillet 2026)",
),
"2026-908 DC": (ResultatCC.CONFORME, "Conforme"),
"2026-909 DC": (
ResultatCC.NON_CONFORMITE_PARTIELLE,
"Censure du portrait-robot génétique et des bases ADN étrangères",
),
}
# Mots-clés de thème. Volontairement conservateurs : mieux vaut un thème
# manquant qu'un thème inventé — les chapitres affinent en phase suivante.
MOTS_CLES_THEMES: dict[Theme, tuple[str, ...]] = {
Theme.JUSTICE: (
"justice", "penal", "criminel", "juge", "avocat", "detention", "victimes",
"privation de liberte", "extradition", "defense", "juridiction",
"consentement", "conjugal", "mariage", "outrage", "delit", "amende",
),
Theme.SECURITE: (
"securite", "ordre public", "police", "retention", "surveillance", "fouille",
"attentat", "tranquillite", "militaire", "defense nationale", "rodeo",
),
Theme.NUMERIQUE: (
"numerique", "reseaux sociaux", "donnees de connexion", "en ligne",
"plateforme", "geolocalisation", "facturation electronique",
),
Theme.SOCIAL: (
"social", "chomage", "travail", "emploi", "petite enfance", "conge",
"professionnalisation", "logement", "formation", "salarie", "enfant",
),
Theme.FISCAL: ("fiscal", "impot", "taxe", "fraude", "conventions fiscales", "octroi de mer"),
Theme.ENVIRONNEMENT: (
"environnement", "ecolog", "inondation", "hydroelectric", "eau", "littoral",
"carbone", "pesticide", "neonicotinoide", "montagne", "zan", "textile",
),
Theme.AGRICULTURE: ("agricole", "agriculture", "exploitation", "peche", "elevage"),
Theme.SANTE: (
"sante", "medecin", "soins", "palliatif", "malade", "handicap", "chlordecone",
"aide a mourir", "psychiatrique", "protoxyde",
),
Theme.MEMOIRE_PATRIMOINE: (
"memoire", "patrimoine", "restitution", "biens culturels", "restes humains",
"code noir", "reparation", "esclavage", "transplantes",
),
Theme.OUTRE_MER: (
"outre-mer", "outre mer", "ultramarin", "guadeloupe", "martinique", "guyane",
"reunion", "mayotte", "drom", "caricom", "caraibe", "saint-martin",
"nouvelle-caledonie", "calédonie", "kali'na", "arawak",
),
Theme.ECONOMIE: (
"economique", "entreprise", "commercial", "creances", "concentration",
"marches publics", "simplification", "pme", "importateur", "concurrence",
),
Theme.MIGRATION: ("migration", "asile", "readmission", "etranger", "frontiere", "expulsion"),
Theme.INSTITUTIONS: (
"organique", "corps electoral", "senatoriales", "constitution", "habilitation",
"collectivite", "prefet", "prefectoral", "etat", "association", "republicain",
"subvention", "dissolution", "hymne", "drapeau", "expression",
),
}
@dataclass(slots=True)
class ResultatSeed:
"""Ce qu'un parseur rend au seed : des textes et un compte-rendu."""
textes: list[Texte] = field(default_factory=list)
avertissements: list[str] = field(default_factory=list)
def __len__(self) -> int:
return len(self.textes)
# ─────────────────────────────────────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────────────────────────────────────
def parser(markdown: str) -> ResultatSeed:
"""Extrait tous les textes de l'annexe récapitulative."""
resultat = ResultatSeed()
tableaux = extraire_tableaux(markdown)
tableau_a = _trouver(tableaux, "N° de loi")
tableau_b = _trouver(tableaux, "Texte")
if tableau_a is None:
resultat.avertissements.append("Tableau A introuvable dans sec10.md")
else:
for ligne in tableau_a.lignes:
try:
resultat.textes.append(_lire_loi_promulguee(ligne))
except Exception as erreur: # noqa: BLE001 — on continue le lot
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
if tableau_b is None:
resultat.avertissements.append("Tableau B introuvable dans sec10.md")
else:
for ligne in tableau_b.lignes:
try:
resultat.textes.extend(_lire_texte_en_cours(ligne))
except Exception as erreur: # noqa: BLE001
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
log.info(
"annexe analysée",
promulguees=len(tableau_a.lignes) if tableau_a else 0,
en_cours=len(tableau_b.lignes) if tableau_b else 0,
textes=len(resultat.textes),
avertissements=len(resultat.avertissements),
)
return resultat
def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
for tableau in tableaux:
if tableau.entetes and tableau.entetes[0] == premiere_colonne:
return tableau
return None
# ─────────────────────────────────────────────────────────────────────────────
# Tableau A — lois promulguées
# ─────────────────────────────────────────────────────────────────────────────
def _lire_loi_promulguee(ligne: dict[str, str]) -> Texte:
brut_numero = nettoyer(ligne["N° de loi"])
correspondance = MOTIF_NUMERO_LOI.match(brut_numero)
if not correspondance:
raise ValueError(f"numéro de loi illisible : {brut_numero!r}")
organique = bool(correspondance.group(1))
numero = correspondance.group(2)
cellule_dates = ligne["Promulgation / JO"]
dates = toutes_les_dates(cellule_dates, annee_defaut=ANNEE_CORPUS)
if not dates:
raise ValueError(f"date de promulgation illisible : {cellule_dates!r}")
date_promulgation = dates[0]
date_jo = dates[1] if len(dates) > 1 else None
objet = nettoyer(ligne["Objet"])
cellule_libertes = ligne["Statut libertés"]
cellule_gpe = ligne["Pertinence Guadeloupe-OM"]
regime, note_regime = _lire_regime(cellule_libertes)
pertinence, note_gpe = _lire_pertinence(cellule_gpe)
evenements = [
Evenement(
date_evenement=date_promulgation,
type_etape=TypeEtape.PROMULGATION,
description=f"Promulgation de la loi n° {numero}",
)
]
if date_jo:
evenements.append(
Evenement(
date_evenement=date_jo,
type_etape=TypeEtape.PUBLICATION_JO,
description="Publication au Journal officiel",
)
)
# Une date postérieure à la promulgation, citée dans la colonne Guadeloupe,
# est une entrée en vigueur différée (« 1er sept. 2026 », « 6 janv. 2027 »).
entree_vigueur = _entree_en_vigueur_differee(cellule_gpe, date_promulgation)
if entree_vigueur:
evenements.append(
Evenement(
date_evenement=entree_vigueur,
type_etape=TypeEtape.ENTREE_VIGUEUR,
description=f"Entrée en vigueur différée au {entree_vigueur.isoformat()}",
previsionnel=entree_vigueur > date_promulgation,
)
)
decisions = _lire_decisions_cc(f"{cellule_libertes} {cellule_gpe}")
return Texte(
id=f"loi-{numero}",
numero_officiel=numero,
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
titre_court=objet,
statut=Statut.PROMULGUEE,
statut_date=date_promulgation,
date_promulgation=date_promulgation,
date_entree_vigueur=entree_vigueur,
themes=_deduire_themes(f"{objet} {cellule_libertes} {cellule_gpe}"),
regime_libertes=regime,
regime_libertes_note=note_regime,
guadeloupe_pertinence=pertinence,
guadeloupe_note=note_gpe,
confiance=Confiance.HIGH,
source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-a",
evenements=evenements,
decisions_cc=decisions,
)
def _entree_en_vigueur_differee(cellule: str, promulgation: date) -> date | None:
"""Repère une date d'application postérieure à la promulgation."""
for candidate in toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS):
if candidate > promulgation:
return candidate
return None
# ─────────────────────────────────────────────────────────────────────────────
# Tableau B — textes en cours
# ─────────────────────────────────────────────────────────────────────────────
def _lire_texte_en_cours(ligne: dict[str, str]) -> list[Texte]:
intitule = nettoyer(ligne["Texte"])
cle = sans_accents(intitule).lower()
cellule_statut = ligne["Statut au 25 juillet 2026"]
cellule_etape = nettoyer(ligne["Prochaine étape"])
cellule_echeance = nettoyer(ligne["Échéance probable"])
cellule_enjeu = nettoyer(ligne["Enjeu libertés principal"])
echeance, libelle_echeance = lire_echeance(cellule_echeance)
# La date de saisine se lit dans la cellule de statut uniquement : la
# colonne « Échéance probable » ne contient que la décision attendue, et
# la confondre avec la saisine daterait celle-ci du mois suivant.
decisions = _lire_decisions_cc(cellule_statut, echeance_par_defaut=echeance)
for prefixe, eclatements in LIGNES_A_ECLATER.items():
if cle.startswith(prefixe):
return [
_construire_texte_en_cours(
slug=e.slug,
titre=e.titre,
type_texte=e.type,
statut=e.statut,
cellule_statut=e.fragment_statut,
etape=cellule_etape,
echeance=echeance,
libelle_echeance=libelle_echeance,
enjeu=e.note or cellule_enjeu,
decisions=[],
)
for e in eclatements
]
slug = _slug_tableau_b(cle, intitule)
statut = _lire_statut(cellule_statut)
type_texte = _lire_type(intitule)
return [
_construire_texte_en_cours(
slug=slug,
titre=intitule,
type_texte=type_texte,
statut=statut,
cellule_statut=cellule_statut,
etape=cellule_etape,
echeance=echeance,
libelle_echeance=libelle_echeance,
enjeu=cellule_enjeu,
decisions=decisions,
)
]
def _construire_texte_en_cours(
*,
slug: str,
titre: str,
type_texte: TypeTexte,
statut: Statut,
cellule_statut: str,
etape: str,
echeance: date | None,
libelle_echeance: str,
enjeu: str,
decisions: list[DecisionCC],
) -> Texte:
statut_propre = nettoyer(cellule_statut)
evenements = _evenements_du_statut(cellule_statut, statut)
label = etape
if libelle_echeance and libelle_echeance.lower() not in etape.lower():
label = f"{etape}{libelle_echeance}" if etape else libelle_echeance
return Texte(
id=slug,
type=type_texte,
titre_court=titre,
statut=statut,
statut_date=_date_du_statut(cellule_statut),
date_adoption=_date_adoption(cellule_statut, statut),
prochaine_echeance=echeance,
prochaine_echeance_label=label or None,
themes=_deduire_themes(f"{titre} {statut_propre} {enjeu}"),
regime_libertes=None, # le tableau B ne cote pas les régimes
guadeloupe_pertinence=None, # renseigné en phase d'enrichissement
points_cles=[enjeu] if enjeu else [],
confiance=Confiance.HIGH,
source_seed="lois-france-2026-guadeloupe_sec10.md#tableau-b",
evenements=evenements,
decisions_cc=decisions,
)
def _slug_tableau_b(cle: str, intitule: str) -> str:
for prefixe, slug in SLUGS_TABLEAU_B.items():
if cle.startswith(prefixe):
return slug
# Repli : slug dérivé de l'intitulé, tronqué pour rester lisible en URL.
mots = re.sub(r"[^a-z0-9]+", "-", sans_accents(intitule).lower()).strip("-")
return "-".join(mots.split("-")[:6])
# ─────────────────────────────────────────────────────────────────────────────
# Lecture des cellules
# ─────────────────────────────────────────────────────────────────────────────
def _lire_regime(cellule: str) -> tuple[RegimeLibertes | None, str | None]:
"""Lit la colonne « Statut libertés » : droits+, contrôle+, mixte, neutre."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
if "droits+" in plat and "neutre" not in plat.split(":")[0]:
regime = RegimeLibertes.DROITS_PLUS
elif "controle+" in plat:
regime = RegimeLibertes.CONTROLE_PLUS
elif plat.startswith("mixte"):
regime = RegimeLibertes.MIXTE
elif "droits+" in plat:
# « Neutre à droits+ » : la source hésite, on retient le sens positif.
regime = RegimeLibertes.DROITS_PLUS
elif plat.startswith("neutre"):
regime = RegimeLibertes.NEUTRE
else:
return None, propre or None
return regime, propre or None
def _lire_pertinence(cellule: str) -> tuple[Pertinence | None, str | None]:
"""Lit « Forte — motif », « Moyenne — motif », « Faible — motif »."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
pertinence: Pertinence | None = None
if plat.startswith("forte"):
pertinence = Pertinence.FORTE
elif plat.startswith("moyenne"):
pertinence = Pertinence.MOYENNE
elif plat.startswith("faible"):
pertinence = Pertinence.FAIBLE
note = propre
for separateur in ("", "", "-"):
if separateur in propre:
note = propre.split(separateur, 1)[1].strip()
break
return pertinence, note or None
# Formes du corpus, testées dans cet ordre : la première qui apparaît gagne.
_FORMES_STATUT: tuple[tuple[str, Statut], ...] = (
("adoptee, non promulguee", Statut.ADOPTEE_NON_PROMULGUEE),
("adopte, non promulgue", Statut.ADOPTEE_NON_PROMULGUEE),
("deposee, non examinee", Statut.DEPOSEE_NON_EXAMINEE),
("depose, non examine", Statut.DEPOSEE_NON_EXAMINEE),
("en navette", Statut.NAVETTE),
("adoptee par une chambre", Statut.NAVETTE),
("adoptee en 1re lecture", Statut.NAVETTE),
("adoptes par le senat", Statut.NAVETTE),
("ratification deposee", Statut.DEPOSEE_NON_EXAMINEE),
("annonce", Statut.ANNONCEE),
("rejete", Statut.REJETEE),
)
def _lire_statut(cellule: str) -> Statut:
"""Traduit la cellule de statut du tableau B en valeur d'énumération."""
plat = sans_accents(nettoyer(cellule)).lower()
# Cas particulier documenté : la loi « Philippine » est adoptée ET déjà
# validée par le Conseil constitutionnel (2026-906 DC du 23 juillet), il ne
# lui manque que la signature. C'est exactement `validee_cc`.
if "conforme" in plat and "promulgation pendante" in plat:
return Statut.VALIDEE_CC
for forme, statut in _FORMES_STATUT:
if forme in plat:
return statut
if plat.startswith("adoptee") or plat.startswith("adopte"):
return Statut.ADOPTEE_NON_PROMULGUEE
raise ValueError(f"statut non reconnu : {nettoyer(cellule)!r}")
def _date_du_statut(cellule: str) -> date | None:
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
return max(dates) if dates else None
def _date_adoption(cellule: str, statut: Statut) -> date | None:
"""Première date citée quand la cellule décrit une adoption."""
if statut in (Statut.DEPOSEE_NON_EXAMINEE, Statut.ANNONCEE):
return None
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
return dates[0] if dates else None
def _evenements_du_statut(cellule: str, statut: Statut) -> list[Evenement]:
"""Reconstruit la timeline lisible dans la cellule de statut."""
propre = nettoyer(cellule)
plat = sans_accents(propre).lower()
dates = toutes_les_dates(cellule, annee_defaut=ANNEE_CORPUS)
evenements: list[Evenement] = []
if not dates:
return evenements
if statut is Statut.ADOPTEE_NON_PROMULGUEE or "adopt" in plat:
etape = (
TypeEtape.ADOPTION_DEFINITIVE
if statut in (Statut.ADOPTEE_NON_PROMULGUEE, Statut.VALIDEE_CC)
else TypeEtape.ADOPTION_1RE_LECTURE
)
evenements.append(
Evenement(date_evenement=dates[0], type_etape=etape, description=propre)
)
elif statut is Statut.DEPOSEE_NON_EXAMINEE:
evenements.append(
Evenement(date_evenement=dates[0], type_etape=TypeEtape.DEPOT, description=propre)
)
# Une saisine du Conseil constitutionnel est un événement à part entière :
# le §7 du cahier des charges l'exige explicitement pour la loi « Riposte ».
# Attention : une cellule peut citer une décision SANS saisine (la loi
# « Philippine » ne mentionne que « déc. 2026-906 DC du 23 juill. »).
# Confondre les deux inventerait une saisine à la date de la décision.
affaires = MOTIF_AFFAIRE.findall(cellule)
date_saisine = _date_de_saisine(cellule)
if _mentionne_une_saisine(cellule):
for affaire in affaires:
quand = date_saisine or (dates[-1] if dates else None)
if quand:
evenements.append(
Evenement(
date_evenement=quand,
type_etape=TypeEtape.SAISINE_CC,
description=f"Saisine du Conseil constitutionnel n° {affaire} DC "
f"du {_en_toutes_lettres(quand)}",
)
)
# Certaines lignes mentionnent la saisine sans numéro d'affaire — la loi
# actualisant la programmation militaire, « Conseil constitutionnel saisi
# le 6 juill. ». L'événement est enregistré quand même : c'est lui qui
# fait apparaître le texte dans la facette « devant le Conseil
# constitutionnel ». Aucune `decision_cc` n'est créée, faute de numéro.
if not affaires and date_saisine:
evenements.append(
Evenement(
date_evenement=date_saisine,
type_etape=TypeEtape.SAISINE_CC,
description="Saisine du Conseil constitutionnel du "
f"{_en_toutes_lettres(date_saisine)} (numéro d'affaire non "
"précisé par la source)",
)
)
# Décision déjà rendue et datée dans la cellule.
for affaire in affaires:
rendue = _date_de_decision(cellule, affaire)
if rendue:
evenements.append(
Evenement(
date_evenement=rendue,
type_etape=TypeEtape.DECISION_CC,
description=f"Décision n° {affaire} DC du {_en_toutes_lettres(rendue)}",
)
)
return evenements
def _mentionne_une_saisine(cellule: str) -> bool:
plat = sans_accents(nettoyer(cellule)).lower()
return "conseil constitutionnel saisi" in plat or "saisin" in plat
_MENTION_SAISINE = re.compile(r"saisi\w*", re.IGNORECASE)
def _date_de_saisine(cellule: str) -> date | None:
"""Date rattachée au mot « saisine », jamais au vote d'adoption.
La recherche est bornée au fragment qui **suit** la mention de la saisine.
Sans cette borne, « Adoptée (15 juill., 291/241) ; quatre saisines réunies
sous le n° 2026-910 DC » daterait la saisine du jour du vote — alors que le
corpus ne la date pas du tout. Mieux vaut aucune date qu'une date fausse.
Formes couvertes : « saisine du 23 juill. », « saisines des 23-24 juill. »
(on retient la dernière), « Conseil constitutionnel saisi le 6 juill. ».
"""
plat = sans_accents(cellule)
mention = _MENTION_SAISINE.search(plat)
if not mention:
return None
apres = cellule[mention.end() :]
dates = toutes_les_dates(apres, annee_defaut=ANNEE_CORPUS)
return dates[-1] if dates else None
_MOIS_LETTRES = (
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
)
def _en_toutes_lettres(d: date) -> str:
jour = "1er" if d.day == 1 else str(d.day)
return f"{jour} {_MOIS_LETTRES[d.month - 1]} {d.year}"
def _lire_type(intitule: str) -> TypeTexte:
plat = sans_accents(intitule).lower()
if plat.startswith("ppl"):
return TypeTexte.PPL
if plat.startswith("pjl"):
return TypeTexte.PJL
if plat.startswith(("accord", "accords")):
return TypeTexte.ACCORD_INTERNATIONAL
if "organique" in plat:
return TypeTexte.LOI_ORGANIQUE
if plat.startswith("loi"):
return TypeTexte.LOI
return TypeTexte.PJL
def _lire_decisions_cc(
fragment: str, *, echeance_par_defaut: date | None = None
) -> list[DecisionCC]:
"""Extrait les affaires « n° 2026-915 DC » citées dans une cellule."""
decisions: list[DecisionCC] = []
vues: set[str] = set()
for numero in MOTIF_AFFAIRE.findall(fragment):
affaire = f"{numero} DC"
if affaire in vues:
continue
vues.add(affaire)
connue = RESULTATS_CC_CONNUS.get(affaire)
if connue:
resultat, resume = connue
decisions.append(
DecisionCC(
numero_affaire=affaire,
date_decision=_date_de_decision(fragment, affaire),
resultat=resultat,
resume=resume,
)
)
else:
decisions.append(
DecisionCC(
numero_affaire=affaire,
date_saisine=_date_de_saisine(fragment),
resultat=ResultatCC.EN_INSTANCE,
date_decision_attendue=echeance_par_defaut,
)
)
return decisions
_DECISION_DU = re.compile(r"dec\.?\s*\d{4}-\d{1,4}\s*DC\s*du\s+([^);,]+)", re.IGNORECASE)
def _date_de_decision(fragment: str, affaire: str) -> date | None:
plat = sans_accents(fragment)
if m := _DECISION_DU.search(plat):
return lire_date(m.group(1), annee_defaut=ANNEE_CORPUS)
return None
def _compiler_motifs_themes() -> dict[Theme, re.Pattern[str]]:
"""Compile un motif par thème, ancré sur des frontières de mot.
Sans cette ancre, « eau » se déclenche sur « résEAUx », « RetaillEAU » et
« drapEAU » — trois faux positifs observés sur le corpus réel. Le suffixe
`\\w*` reste autorisé pour attraper les flexions (« agricole/agricoles »).
"""
motifs: dict[Theme, re.Pattern[str]] = {}
for theme, mots in MOTS_CLES_THEMES.items():
alternatives = "|".join(re.escape(mot) for mot in mots)
motifs[theme] = re.compile(rf"(?<!\w)(?:{alternatives})\w*", re.IGNORECASE)
return motifs
MOTIFS_THEMES = _compiler_motifs_themes()
def _deduire_themes(texte: str) -> list[Theme]:
"""Thèmes déduits par mots-clés, sans jamais dépasser quatre entrées."""
plat = sans_accents(texte).lower()
scores: list[tuple[int, Theme]] = []
for theme, motif in MOTIFS_THEMES.items():
occurrences = len(set(motif.findall(plat)))
if occurrences:
scores.append((occurrences, theme))
scores.sort(key=lambda paire: (-paire[0], str(paire[1])))
return [theme for _, theme in scores[:4]]
def extraire_marqueurs_annexe(markdown: str) -> dict[str, list[str]]:
"""Associe chaque ligne de tableau aux marqueurs de citation qu'elle porte.
Sert au rattachement des sources en phase suivante : la clé est le numéro de
loi ou l'intitulé, la valeur la liste des marqueurs bruts (« dim07-24 »).
"""
associations: dict[str, list[str]] = {}
for tableau in extraire_tableaux(markdown):
if not tableau.entetes:
continue
colonne_cle = tableau.entetes[0]
for ligne in tableau.lignes:
cle = nettoyer(ligne[colonne_cle])
marqueurs = [
m.brut for cellule in ligne.values() for m in extraire_marqueurs(cellule)
]
if marqueurs:
associations.setdefault(cle, []).extend(marqueurs)
return associations