235 lines
8.6 KiB
Python
235 lines
8.6 KiB
Python
"""Lecture de la bibliographie consolidée (`lois-france-2026-guadeloupe_ref.md`).
|
|||
|
|
|
||
|
|
481 références au format ::
|
||
|
|
|
||
|
|
[1] Aida. Loi ndeg 2026 554 290626 visant a relancer investissements secteur
|
||
|
|
lhydroelectricite [EB/OL]. 29-30 juin 2026 (publication). https://…
|
||
|
|
|
||
|
|
C'est la seule liste du corpus intégralement pourvue d'URLs : elle sert de
|
||
|
|
filet pour garantir qu'aucun texte n'entre en base sans lien vérifiable.
|
||
|
|
|
||
|
|
Le titre y est translittéré sans accent ni ponctuation (« ndeg » pour « n° »),
|
||
|
|
ce qui interdit le rapprochement par libellé. En revanche les numéros de loi
|
||
|
|
y survivent, sous forme éclatée : « 2026 554 » pour « 2026-554 ». C'est ce
|
||
|
|
motif qui est exploité pour rattacher une référence à un texte.
|
||
|
|
"""
|
||
|
|
|
||
|
|
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, Source
|
||
|
|
from pipeline.parseurs.dates_fr import lire_date, sans_accents
|
||
|
|
from pipeline.parseurs.markdown import nettoyer
|
||
|
|
|
||
|
|
log = logger("parseur.bibliographie")
|
||
|
|
|
||
|
|
ANNEE_CORPUS = 2026
|
||
|
|
|
||
|
|
_LIGNE_REFERENCE = re.compile(r"^\[(\d+)\]\s+(.*)$")
|
||
|
|
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||
|
|
|
||
|
|
# « ndeg 2026 554 », « 2026-554 », « n° 2026-491 » : le numéro de loi survit à
|
||
|
|
# la translittération sous une forme ou une autre.
|
||
|
|
_NUMERO_LOI = re.compile(r"\b(?:ndeg\s*|n°\s*|no\s*)?(\d{4})[\s-](\d{3,4})\b")
|
||
|
|
|
||
|
|
# Numéro d'affaire du Conseil constitutionnel, également translittéré.
|
||
|
|
_AFFAIRE_CC = re.compile(r"\b(\d{4})[\s-](\d{3,4})\s*DC\b", re.IGNORECASE)
|
||
|
|
|
||
|
|
# Numéro de document parlementaire, sous ses trois graphies : la cote brute
|
||
|
|
# « n° 3025 », la cote de dossier de l'Assemblée « l17b3025 » et celle du Sénat
|
||
|
|
# « ppl25-691 » / « pjl25-890 ». C'est souvent le seul point commun entre la
|
||
|
|
# note laconique d'un rapport de dimension (« Texte n° 3025 ») et l'entrée
|
||
|
|
# bibliographique qui porte l'URL (« L17b3025 proposition loi »).
|
||
|
|
_DOCUMENT_AN = re.compile(r"\bl\d{2}b(\d{3,4})\b", re.IGNORECASE)
|
||
|
|
_DOCUMENT_SENAT = re.compile(r"\b(?:ppl|pjl)\d{2}-(\d{3,4})\b", re.IGNORECASE)
|
||
|
|
_DOCUMENT_NUMERO = re.compile(r"\bn(?:°|deg|o)\s*(\d{3,4})\b", re.IGNORECASE)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(slots=True)
|
||
|
|
class Reference:
|
||
|
|
"""Une entrée de la bibliographie consolidée."""
|
||
|
|
|
||
|
|
numero: int
|
||
|
|
editeur: str | None
|
||
|
|
titre: str
|
||
|
|
date_publication: date | None
|
||
|
|
url: str | None
|
||
|
|
numeros_cites: list[str] = field(default_factory=list)
|
||
|
|
affaires_citees: list[str] = field(default_factory=list)
|
||
|
|
documents_cites: list[str] = field(default_factory=list)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def marqueur(self) -> str:
|
||
|
|
return f"ref-{self.numero}"
|
||
|
|
|
||
|
|
def en_source(self) -> Source | None:
|
||
|
|
if not self.url:
|
||
|
|
return None
|
||
|
|
return Source(
|
||
|
|
url=self.url,
|
||
|
|
titre=self.titre[:280] or None,
|
||
|
|
editeur=self.editeur,
|
||
|
|
date_publication=self.date_publication,
|
||
|
|
confiance=Confiance.MEDIUM,
|
||
|
|
marqueur=self.marqueur,
|
||
|
|
fichier_origine="lois-france-2026-guadeloupe_ref.md",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def parser(markdown: str) -> list[Reference]:
|
||
|
|
"""Extrait toutes les références de la bibliographie."""
|
||
|
|
references: list[Reference] = []
|
||
|
|
|
||
|
|
for ligne in markdown.splitlines():
|
||
|
|
correspondance = _LIGNE_REFERENCE.match(ligne.strip())
|
||
|
|
if not correspondance:
|
||
|
|
continue
|
||
|
|
references.append(_lire_reference(int(correspondance.group(1)), correspondance.group(2)))
|
||
|
|
|
||
|
|
avec_url = sum(1 for r in references if r.url)
|
||
|
|
log.debug("bibliographie analysée", references=len(references), avec_url=avec_url)
|
||
|
|
return references
|
||
|
|
|
||
|
|
|
||
|
|
def _lire_reference(numero: int, corps: str) -> Reference:
|
||
|
|
url = None
|
||
|
|
if trouvee := _URL.search(corps):
|
||
|
|
url = trouvee.group(0).rstrip(".,;)")
|
||
|
|
|
||
|
|
sans_lien = _URL.sub("", corps).strip()
|
||
|
|
|
||
|
|
editeur: str | None = None
|
||
|
|
titre = sans_lien
|
||
|
|
if ". " in sans_lien:
|
||
|
|
candidat, reste = sans_lien.split(". ", 1)
|
||
|
|
# Un éditeur est court : au-delà, c'est que la phrase a été coupée sur
|
||
|
|
# une abréviation (« n° 2026-491. »).
|
||
|
|
if len(candidat) <= 60:
|
||
|
|
editeur, titre = candidat.strip(), reste
|
||
|
|
|
||
|
|
titre = titre.split("[EB/OL]", 1)[0].strip(" .;")
|
||
|
|
|
||
|
|
return Reference(
|
||
|
|
numero=numero,
|
||
|
|
editeur=nettoyer(editeur) if editeur else None,
|
||
|
|
titre=nettoyer(titre)[:400],
|
||
|
|
date_publication=lire_date(sans_lien, annee_defaut=ANNEE_CORPUS),
|
||
|
|
url=url,
|
||
|
|
numeros_cites=_numeros_de_loi(sans_lien),
|
||
|
|
affaires_citees=_affaires_cc(sans_lien),
|
||
|
|
documents_cites=numeros_de_document(f"{sans_lien} {url or ''}"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _numeros_de_loi(texte: str) -> list[str]:
|
||
|
|
"""Numéros de loi cités, normalisés au format « 2026-554 »."""
|
||
|
|
plat = sans_accents(texte).lower()
|
||
|
|
trouves: list[str] = []
|
||
|
|
|
||
|
|
for annee, rang in _NUMERO_LOI.findall(plat):
|
||
|
|
# Seules les années plausibles pour la fenêtre de veille comptent.
|
||
|
|
if annee not in {"2024", "2025", "2026"}:
|
||
|
|
continue
|
||
|
|
# « programmation militaire 2024-2030 » n'est pas la loi n° 2024-2030 :
|
||
|
|
# deux millésimes croissants forment une plage, pas un numéro. Le test
|
||
|
|
# inverse laisse passer les vrais numéros à quatre chiffres, dont le
|
||
|
|
# rang est toujours inférieur au millésime (loi n° 2021-1947).
|
||
|
|
if 1900 <= int(rang) <= 2100 and int(rang) > int(annee):
|
||
|
|
continue
|
||
|
|
numero = f"{annee}-{int(rang)}"
|
||
|
|
if numero not in trouves:
|
||
|
|
trouves.append(numero)
|
||
|
|
|
||
|
|
return trouves
|
||
|
|
|
||
|
|
|
||
|
|
def _affaires_cc(texte: str) -> list[str]:
|
||
|
|
plat = sans_accents(texte)
|
||
|
|
trouvees: list[str] = []
|
||
|
|
for annee, rang in _AFFAIRE_CC.findall(plat):
|
||
|
|
affaire = f"{annee}-{int(rang)} DC"
|
||
|
|
if affaire not in trouvees:
|
||
|
|
trouvees.append(affaire)
|
||
|
|
return trouvees
|
||
|
|
|
||
|
|
|
||
|
|
_LIGNE_CORRESPONDANCE = re.compile(r"^\|\s*(dim\d{2}|phase5|insight)\s*\(?[^|]*\|\s*([^|]*)\|")
|
||
|
|
_PLAGE = re.compile(r"(\d+)\s*[–—-]\s*(\d+)")
|
||
|
|
|
||
|
|
|
||
|
|
def lire_table_de_correspondance(markdown: str) -> dict[str, set[int]]:
|
||
|
|
"""Lit la table « préfixe de citation → références [N] correspondantes ».
|
||
|
|
|
||
|
|
La bibliographie se termine par un tableau qui indique, pour chaque rapport
|
||
|
|
de dimension, quelles références globales lui appartiennent. C'est ce
|
||
|
|
tableau qui rend possible le rapprochement d'une note sans URL avec son
|
||
|
|
entrée bibliographique : sans lui, il faudrait chercher parmi 481 candidates
|
||
|
|
au lieu d'une cinquantaine.
|
||
|
|
"""
|
||
|
|
correspondances: dict[str, set[int]] = {}
|
||
|
|
|
||
|
|
for ligne in markdown.splitlines():
|
||
|
|
entree = _LIGNE_CORRESPONDANCE.match(ligne.strip())
|
||
|
|
if not entree:
|
||
|
|
continue
|
||
|
|
|
||
|
|
prefixe, cellule = entree.group(1), entree.group(2)
|
||
|
|
numeros: set[int] = set()
|
||
|
|
|
||
|
|
for morceau in cellule.split(","):
|
||
|
|
morceau = morceau.strip()
|
||
|
|
if plage := _PLAGE.search(morceau):
|
||
|
|
debut, fin = int(plage.group(1)), int(plage.group(2))
|
||
|
|
if debut <= fin:
|
||
|
|
numeros.update(range(debut, fin + 1))
|
||
|
|
elif morceau.isdigit():
|
||
|
|
numeros.add(int(morceau))
|
||
|
|
|
||
|
|
if numeros:
|
||
|
|
correspondances[prefixe] = numeros
|
||
|
|
|
||
|
|
return correspondances
|
||
|
|
|
||
|
|
|
||
|
|
def numeros_de_document(texte: str) -> list[str]:
|
||
|
|
"""Cotes de documents parlementaires citées, normalisées en chiffres seuls."""
|
||
|
|
plat = sans_accents(texte)
|
||
|
|
trouves: list[str] = []
|
||
|
|
for motif in (_DOCUMENT_AN, _DOCUMENT_SENAT, _DOCUMENT_NUMERO):
|
||
|
|
for numero in motif.findall(plat):
|
||
|
|
depouille = str(int(numero))
|
||
|
|
if depouille not in trouves:
|
||
|
|
trouves.append(depouille)
|
||
|
|
return trouves
|
||
|
|
|
||
|
|
|
||
|
|
def indexer_par_document(references: list[Reference]) -> dict[str, list[Reference]]:
|
||
|
|
"""Index cote de document → références qui la citent."""
|
||
|
|
index: dict[str, list[Reference]] = {}
|
||
|
|
for reference in references:
|
||
|
|
for document in reference.documents_cites:
|
||
|
|
index.setdefault(document, []).append(reference)
|
||
|
|
return index
|
||
|
|
|
||
|
|
|
||
|
|
def indexer_par_numero(references: list[Reference]) -> dict[str, list[Reference]]:
|
||
|
|
"""Index numéro de loi → références qui le citent."""
|
||
|
|
index: dict[str, list[Reference]] = {}
|
||
|
|
for reference in references:
|
||
|
|
for numero in reference.numeros_cites:
|
||
|
|
index.setdefault(numero, []).append(reference)
|
||
|
|
return index
|
||
|
|
|
||
|
|
|
||
|
|
def indexer_par_affaire(references: list[Reference]) -> dict[str, list[Reference]]:
|
||
|
|
"""Index numéro d'affaire DC → références qui la citent."""
|
||
|
|
index: dict[str, list[Reference]] = {}
|
||
|
|
for reference in references:
|
||
|
|
for affaire in reference.affaires_citees:
|
||
|
|
index.setdefault(affaire, []).append(reference)
|
||
|
|
return index
|