Files
Cyber MawonajandClaude Opus 5 c36b8dc631 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>
2026-07-25 18:22:18 -04:00

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)]