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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c36b8dc631
commit
c28243cf87
@@ -0,0 +1,234 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user