Files
veye-lalwa/pipeline/parseurs/bibliographie.py
T
Cyber MawonajandClaude Opus 5 c28243cf87 Phase 3 : sources, citations et seed complet — 368 sources, 0 texte orphelin
Parseurs :
- blocs_claim : les quatre dialectes des rapports de dimension. Le cahier des
  charges en postulait un seul ; sans les quatre, dim05 à dim12 étaient perdus,
  soit les dimensions associations, entreprises, migration et calendrier.
- notes_bas_page : 258 notes, dont 104 sans URL. Les identifiants JORFTEXT
  donnent lieu à une URL canonique reconstruite (schéma fixe, rien d'inventé) ;
  les autres restent sans lien plutôt que d'être devinées.
- bibliographie : 481 références, toutes liées. Indexées par numéro de loi,
  par affaire DC et par cote de document parlementaire — cette dernière étant
  la seule identité d'un texte déposé et non examiné.
- chapitres : rattachement phrase par phrase, résumés repris tels quels.

Résolution des citations : 100 % des 332 marqueurs du rapport, 93 % avec un
lien vérifiable. Il a fallu six conventions de numérotation, une par famille de
dimension (marqueur de fin, en-tête de claim, section, rang, note, section.rang).

Trois pièges corrigés, tous couverts par des tests :
- « décision n° 2026-903 DC » lu comme une loi, ce qui volait à la loi 2026-403
  les phrases — et donc les sources — qui parlaient d'elle
- retrait des marqueurs laissant « … dans les DROM . » ; corrigé à la source
  sans toucher aux espaces avant « ; » et « » » qu'exige le français
- « programmation militaire 2024-2030 » lu comme la loi n° 2024-2030

make seed : 52 textes, 368 sources, 83 événements, 12 affaires CC, 7 analyses.
Zéro texte sans source URL (critère §8.2). Les 2 textes sans résumé et les 17
pertinences déduites sont marqués « à vérifier » avec leur motif.

99 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:43:03 -04:00

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