"""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