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>
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""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)]
|