Phase 2 : parseur de l'annexe sec10 — 52 textes, 12 affaires CC
Briques réutilisables : - dates_fr : lit les formes réelles du corpus (« 23 avr. 2026 (JO 24 avr.) », « 1er juill. », « 24.07.2026 ») et distingue une date exacte d'une échéance prévisionnelle (« Fin août 2026 » → situé au 25, libellé conservé, jamais présenté comme un fait) - markdown : découpage des tableaux (barres échappées, lignes courtes), nettoyage, extraction des marqueurs [^dim07-24^] / [^phase5-3.1^] Parseur sec10 : - 27 lois promulguées + 21 lignes simples + 2 lignes éclatées en 4 textes = 52 - éclatement justifié : les lignes « accords Kazakhstan et Colombie » et « MACF » décrivent chacune deux textes de statuts différents - migration 002 : regime_libertes stocké à part, faute de savoir quel public la cotation globale du rapport vise — dérouler « droits+ » sur les trois publics aurait inventé une ventilation absente de la source - migration 003 : la facette « devant le CC » couvre aussi les saisines sans numéro d'affaire (programmation militaire), sinon 6 textes au lieu de 7 Trois pièges du corpus, corrigés et couverts par des tests : - « eau » matchait comme sous-chaîne dans réseaux / drapeau / Retailleau - une décision citée sans saisine créait une fausse saisine (loi Philippine) - la date de saisine était pêchée dans la colonne « échéance », datant la saisine de l'aide à mourir du mois suivant 72 tests, 90 % de couverture sur pipeline/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
31d3935bce
commit
c36b8dc631
+2
-1
@@ -101,7 +101,8 @@ _CHAMPS_TEXTE = (
|
|||||||
"id", "numero_officiel", "type", "titre_court", "titre_officiel",
|
"id", "numero_officiel", "type", "titre_court", "titre_officiel",
|
||||||
"statut", "statut_date", "date_depot", "date_adoption", "date_promulgation",
|
"statut", "statut_date", "date_depot", "date_adoption", "date_promulgation",
|
||||||
"date_entree_vigueur", "prochaine_echeance", "prochaine_echeance_label",
|
"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",
|
"resume", "points_cles", "confiance", "source_seed",
|
||||||
"a_verifier", "motif_verification",
|
"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;
|
||||||
@@ -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;
|
||||||
@@ -82,6 +82,15 @@ class Pertinence(StrEnum):
|
|||||||
FAIBLE = "faible"
|
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):
|
class Confiance(StrEnum):
|
||||||
HIGH = "high"
|
HIGH = "high"
|
||||||
MEDIUM = "medium"
|
MEDIUM = "medium"
|
||||||
@@ -258,6 +267,9 @@ class Texte(BaseModel):
|
|||||||
themes: list[Theme] = Field(default_factory=list)
|
themes: list[Theme] = Field(default_factory=list)
|
||||||
impacts: dict[Public, Impact] = Field(default_factory=dict)
|
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_pertinence: Pertinence | None = None
|
||||||
guadeloupe_note: str | 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()},
|
{str(p): {"sens": str(i.sens), "note": i.note} for p, i in self.impacts.items()},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
|
"regime_libertes": str(self.regime_libertes) if self.regime_libertes else None,
|
||||||
|
"regime_libertes_note": self.regime_libertes_note,
|
||||||
"guadeloupe_pertinence": (
|
"guadeloupe_pertinence": (
|
||||||
str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None
|
str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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)]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user