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,91 @@
|
||||
"""Lecture des analyses transversales (`research/lois-2026_insight.md`).
|
||||
|
||||
Sept analyses, chacune au format ::
|
||||
|
||||
## Insight 3 — Le 1er septembre 2026 : « big bang » silencieux …
|
||||
**Dérivé de :** dim08, dim09, dim02, dim12, dim03.
|
||||
<corps>
|
||||
**Implications :** …
|
||||
**Confiance : high.**
|
||||
|
||||
Elles alimentent l'encart « point de veille » du tableau de bord. Elles ne
|
||||
portent aucune source primaire propre — la table de correspondance de la
|
||||
bibliographie le dit — et ne sont donc jamais présentées comme des faits
|
||||
sourcés, mais comme des lectures d'ensemble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Insight
|
||||
from pipeline.parseurs.markdown import decouper_sections, nettoyer
|
||||
|
||||
log = logger("parseur.analyses")
|
||||
|
||||
_TITRE = re.compile(r"^Insight\s+(\d+)\s*[—–-]\s*(.+)$", re.IGNORECASE)
|
||||
_DERIVE = re.compile(r"\*\*Dérivé de\s*:\*\*\s*(.+)", re.IGNORECASE)
|
||||
_IMPLICATIONS = re.compile(r"\*\*Implications\s*:\*\*\s*(.+)", re.IGNORECASE)
|
||||
_CONFIANCE = re.compile(r"\*\*Confiance\s*:\s*([a-zé-]+)", re.IGNORECASE)
|
||||
_DIMENSION = re.compile(r"\b(dim\d{2}|phase\s?5)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def parser(markdown: str) -> list[Insight]:
|
||||
"""Extrait les analyses transversales du fichier d'insights."""
|
||||
analyses: list[Insight] = []
|
||||
|
||||
for section in decouper_sections(markdown, niveau_max=2):
|
||||
entete = _TITRE.match(section.titre.strip())
|
||||
if not entete:
|
||||
continue
|
||||
|
||||
corps_brut = section.corps
|
||||
implications = None
|
||||
if trouve := _IMPLICATIONS.search(corps_brut):
|
||||
implications = nettoyer(trouve.group(1))
|
||||
|
||||
confiance = "medium"
|
||||
if trouve := _CONFIANCE.search(corps_brut):
|
||||
brut = trouve.group(1).lower()
|
||||
confiance = {"high": "high", "élevée": "high", "medium": "medium", "low": "low"}.get(
|
||||
brut, "medium"
|
||||
)
|
||||
|
||||
derive_de: list[str] = []
|
||||
if trouve := _DERIVE.search(corps_brut):
|
||||
derive_de = [
|
||||
d.lower().replace(" ", "") for d in _DIMENSION.findall(trouve.group(1))
|
||||
]
|
||||
|
||||
corps = _corps_utile(corps_brut)
|
||||
|
||||
analyses.append(
|
||||
Insight(
|
||||
numero=int(entete.group(1)),
|
||||
titre=nettoyer(entete.group(2)),
|
||||
corps=corps,
|
||||
implications=implications,
|
||||
confiance=confiance,
|
||||
derive_de=derive_de,
|
||||
fichier_origine="research/lois-2026_insight.md",
|
||||
)
|
||||
)
|
||||
|
||||
log.info("analyses transversales lues", analyses=len(analyses))
|
||||
return analyses
|
||||
|
||||
|
||||
def _corps_utile(corps: str) -> str:
|
||||
"""Corps de l'analyse, sans les lignes de métadonnées."""
|
||||
lignes: list[str] = []
|
||||
for ligne in corps.splitlines():
|
||||
depouillee = ligne.strip()
|
||||
if not depouillee:
|
||||
continue
|
||||
if _DERIVE.match(depouillee) or _IMPLICATIONS.match(depouillee):
|
||||
continue
|
||||
if _CONFIANCE.match(depouillee):
|
||||
continue
|
||||
lignes.append(nettoyer(depouillee))
|
||||
return " ".join(lignes).strip()
|
||||
@@ -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
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Lecture des blocs sourcés des rapports de dimension (`research/dim01` → `dim12`).
|
||||
|
||||
Le cahier des charges postule un format unique
|
||||
`Claim / Source / URL / Date / Excerpt / Confidence`. Le corpus réel en compte
|
||||
**quatre dialectes**, produits par des agents de recherche différents :
|
||||
|
||||
- **A — claim plat** (dim01 à dim04, dim07) ::
|
||||
|
||||
Claim: … [^24^]
|
||||
Source: Vie-publique.fr
|
||||
URL: https://…
|
||||
Confidence: high
|
||||
|
||||
- **B — claim numéroté en gras** (dim05) :: ``**Claim 3 :** … [^3^]``
|
||||
|
||||
- **C — claim en liste** (dim10, portions de dim06 et dim08) ::
|
||||
|
||||
**Claim 1.2** — …
|
||||
- **Source** : Conseil d'État
|
||||
- **URL** : https://…
|
||||
|
||||
- **D — prose et notes** (dim06, dim08, dim09, dim11, dim12) : aucun bloc, les
|
||||
sources vivent dans une liste de notes en fin de fichier (voir
|
||||
`notes_bas_page`).
|
||||
|
||||
Un parseur qui n'aurait vu que le dialecte A perdrait dim05 à dim12, c'est-à-dire
|
||||
les dimensions associations, entreprises, migration et calendrier.
|
||||
"""
|
||||
|
||||
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
|
||||
from pipeline.parseurs.markdown import MOTIF_MARQUEUR, nettoyer
|
||||
|
||||
log = logger("parseur.claims")
|
||||
|
||||
ANNEE_CORPUS = 2026
|
||||
|
||||
# Une ligne de claim, tous dialectes : « Claim: », « **Claim 3 :** »,
|
||||
# « **Claim 1.2** — », « - **Claim**: », et la forme de la validation de
|
||||
# phase 5 qui glisse une parenthèse entre le numéro et le deux-points :
|
||||
# « **Claim 1.1 (décisif, source primaire) : … » — sans quoi ce fichier ne
|
||||
# rend que 2 blocs sur 24.
|
||||
_LIGNE_CLAIM = re.compile(
|
||||
r"^\s*(?:[-*]\s*)?\*{0,2}\s*Claim\s*([\d.]*)\s*(?:\([^)]*\))?\s*\*{0,2}\s*[:—–-]\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Un champ du bloc, avec ou sans puce, avec ou sans gras, avec le deux-points
|
||||
# à l'intérieur ou à l'extérieur des astérisques.
|
||||
_LIGNE_CHAMP = re.compile(
|
||||
r"^\s*(?:[-*]\s*)?\*{0,2}\s*"
|
||||
r"(Source|URL|Date|Excerpt|Extrait|Contexte|Context|Confidence|Confiance)"
|
||||
r"\s*\*{0,2}\s*:\s*\*{0,2}\s*(.*?)\s*\*{0,2}\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_CHAMPS = {
|
||||
"source": "source",
|
||||
"url": "url",
|
||||
"date": "date",
|
||||
"excerpt": "excerpt",
|
||||
"extrait": "excerpt",
|
||||
"context": "contexte",
|
||||
"contexte": "contexte",
|
||||
"confidence": "confiance",
|
||||
"confiance": "confiance",
|
||||
}
|
||||
|
||||
# Formes relevées dans le corpus, du plus fort au plus faible.
|
||||
_CONFIANCES: tuple[tuple[str, Confiance], ...] = (
|
||||
("tres elevee", Confiance.HIGH),
|
||||
("très élevée", Confiance.HIGH),
|
||||
("medium-high", Confiance.MEDIUM),
|
||||
("moyenne-haute", Confiance.MEDIUM),
|
||||
("high", Confiance.HIGH),
|
||||
("haute", Confiance.HIGH),
|
||||
("elevee", Confiance.HIGH),
|
||||
("élevée", Confiance.HIGH),
|
||||
("medium", Confiance.MEDIUM),
|
||||
("moyenne", Confiance.MEDIUM),
|
||||
("low", Confiance.LOW),
|
||||
("faible", Confiance.LOW),
|
||||
)
|
||||
|
||||
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BlocClaim:
|
||||
"""Une affirmation sourcée extraite d'un rapport de dimension."""
|
||||
|
||||
dimension: str # « dim07 »
|
||||
affirmation: str
|
||||
marqueurs: list[str] = field(default_factory=list) # numéros locaux : ["24"]
|
||||
editeur: str | None = None
|
||||
urls: list[str] = field(default_factory=list)
|
||||
date_publication: date | None = None
|
||||
extrait: str | None = None
|
||||
contexte: str | None = None
|
||||
confiance: Confiance = Confiance.MEDIUM
|
||||
ligne: int = 0
|
||||
|
||||
def en_sources(self) -> list[Source]:
|
||||
"""Convertit le bloc en sources exploitables — une par URL citée."""
|
||||
sources: list[Source] = []
|
||||
for url in self.urls:
|
||||
try:
|
||||
sources.append(
|
||||
Source(
|
||||
url=url,
|
||||
titre=self.affirmation[:280] or None,
|
||||
editeur=self.editeur,
|
||||
date_publication=self.date_publication,
|
||||
extrait_verbatim=self.extrait,
|
||||
contexte=self.contexte,
|
||||
confiance=self.confiance,
|
||||
marqueur=f"{self.dimension}-{self.marqueurs[0]}"
|
||||
if self.marqueurs
|
||||
else self.dimension,
|
||||
fichier_origine=f"research/lois-2026_{self.dimension}.md",
|
||||
)
|
||||
)
|
||||
except ValueError as erreur: # URL inexploitable : on ignore, sans bruit
|
||||
log.debug("URL rejetée", url=url, motif=str(erreur))
|
||||
return sources
|
||||
|
||||
|
||||
def parser(markdown: str, dimension: str) -> list[BlocClaim]:
|
||||
"""Extrait tous les blocs sourcés d'un rapport de dimension."""
|
||||
blocs: list[BlocClaim] = []
|
||||
courant: BlocClaim | None = None
|
||||
|
||||
for numero, ligne in enumerate(markdown.splitlines(), start=1):
|
||||
if debut := _LIGNE_CLAIM.match(ligne):
|
||||
courant = _ouvrir_bloc(debut.group(2), dimension, numero)
|
||||
blocs.append(courant)
|
||||
continue
|
||||
|
||||
if courant is None:
|
||||
continue
|
||||
|
||||
if champ := _LIGNE_CHAMP.match(ligne):
|
||||
_remplir(courant, _CHAMPS[champ.group(1).lower()], champ.group(2))
|
||||
continue
|
||||
|
||||
# Un titre de section referme le bloc en cours ; le reste (prose,
|
||||
# lignes vides) est ignoré sans le refermer, car certains blocs sont
|
||||
# entrecoupés de commentaires du rédacteur.
|
||||
if ligne.lstrip().startswith("#"):
|
||||
courant = None
|
||||
|
||||
complets = [b for b in blocs if b.urls]
|
||||
log.debug(
|
||||
"blocs analysés",
|
||||
dimension=dimension,
|
||||
blocs=len(blocs),
|
||||
avec_url=len(complets),
|
||||
)
|
||||
return blocs
|
||||
|
||||
|
||||
def _ouvrir_bloc(affirmation: str, dimension: str, ligne: int) -> BlocClaim:
|
||||
marqueurs = MOTIF_MARQUEUR.findall(affirmation)
|
||||
return BlocClaim(
|
||||
dimension=dimension,
|
||||
affirmation=nettoyer(affirmation),
|
||||
marqueurs=[m for m in marqueurs if m.isdigit() or "." in m],
|
||||
ligne=ligne,
|
||||
)
|
||||
|
||||
|
||||
def _remplir(bloc: BlocClaim, champ: str, valeur: str) -> None:
|
||||
valeur = valeur.strip()
|
||||
if not valeur:
|
||||
return
|
||||
|
||||
match champ:
|
||||
case "source":
|
||||
bloc.editeur = nettoyer(valeur)[:200] or None
|
||||
case "url":
|
||||
# Une ligne peut citer plusieurs URLs séparées par « ; ».
|
||||
bloc.urls.extend(u for u in _URL.findall(valeur) if u not in bloc.urls)
|
||||
case "date":
|
||||
if lue := lire_date(valeur, annee_defaut=ANNEE_CORPUS):
|
||||
bloc.date_publication = lue
|
||||
case "excerpt":
|
||||
bloc.extrait = _nettoyer_extrait(valeur)
|
||||
case "contexte":
|
||||
bloc.contexte = nettoyer(valeur)[:1000] or None
|
||||
case "confiance":
|
||||
bloc.confiance = lire_confiance(valeur)
|
||||
|
||||
|
||||
def _nettoyer_extrait(valeur: str) -> str | None:
|
||||
"""Conserve l'extrait verbatim, sans les guillemets ni l'italique du corpus."""
|
||||
extrait = valeur.strip()
|
||||
for ouvrant, fermant in (("«", "»"), ('"', '"'), ("“", "”")):
|
||||
extrait = extrait.strip().removeprefix(ouvrant).removesuffix(fermant).strip()
|
||||
extrait = extrait.replace("*", "").strip()
|
||||
extrait = MOTIF_MARQUEUR.sub("", extrait)
|
||||
return " ".join(extrait.split())[:2000] or None
|
||||
|
||||
|
||||
def lire_confiance(valeur: str) -> Confiance:
|
||||
"""Traduit les huit formes de confiance rencontrées dans le corpus."""
|
||||
plat = valeur.lower().replace("*", "").strip()
|
||||
for forme, confiance in _CONFIANCES:
|
||||
if plat.startswith(forme):
|
||||
return confiance
|
||||
return Confiance.MEDIUM
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Lecture des chapitres du rapport consolidé (`_sec00.md` → `_sec10.md`).
|
||||
|
||||
Les chapitres sont la seule partie rédigée du corpus : c'est d'eux que
|
||||
proviennent les résumés et les points clés. Ils désignent les textes de deux
|
||||
façons — par numéro (« la loi n° 2026-403 du 26 mai ») et par nom d'usage
|
||||
(« la loi "Riposte" », « l'aide à mourir ») — et sèment des marqueurs de
|
||||
citation à chaque affirmation.
|
||||
|
||||
Le rattachement se fait **phrase par phrase** : les marqueurs d'une phrase vont
|
||||
au texte que cette phrase nomme, pas à tous les textes du paragraphe. Sans quoi
|
||||
un paragraphe traitant de six lois attribuerait ses vingt sources aux six.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pipeline import chemins
|
||||
from pipeline.journal import logger
|
||||
from pipeline.parseurs.dates_fr import sans_accents
|
||||
from pipeline.parseurs.markdown import decouper_sections, extraire_marqueurs, nettoyer
|
||||
|
||||
log = logger("parseur.chapitres")
|
||||
|
||||
# « loi n° 2026-403 », « loi organique n° 2026-410 », « n° 2026-491 du 12 juin ».
|
||||
# La négation finale écarte les numéros d'affaire du Conseil constitutionnel :
|
||||
# sans elle, « déclarée conforme par la décision n° 2026-903 DC, puis promulguée
|
||||
# le 26 mai » désignerait une loi « 2026-903 » qui n'existe pas, et la phrase —
|
||||
# avec ses sources — serait volée à la loi n° 2026-403 dont elle parle.
|
||||
_NUMERO_LOI = re.compile(r"n°\s*(\d{4}-\d{1,4})\b(?!\s*DC)")
|
||||
# Découpage en phrases : point suivi d'une espace et d'une majuscule ou d'un
|
||||
# guillemet ouvrant. Les abréviations du corpus (« n° », « art. », « déc. »,
|
||||
# « juill. ») sont protégées par l'exigence de majuscule qui suit.
|
||||
_FIN_DE_PHRASE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÀÂÉÈÊÎÔÙÜ«])")
|
||||
|
||||
# Noms d'usage employés par les chapitres pour désigner un texte non promulgué.
|
||||
# La clé est cherchée sans accent ni casse ; la valeur est l'identifiant posé
|
||||
# par le parseur de l'annexe.
|
||||
NOMS_D_USAGE: dict[str, str] = {
|
||||
"riposte": "loi-riposte",
|
||||
"ripost": "loi-riposte",
|
||||
"aide a mourir": "ppl-aide-a-mourir",
|
||||
"philippine": "loi-philippine",
|
||||
"reseaux sociaux": "ppl-reseaux-sociaux-mineurs",
|
||||
"moins de 15 ans": "ppl-reseaux-sociaux-mineurs",
|
||||
"urgence agricole": "loi-urgence-agricole",
|
||||
"programmation militaire": "loi-programmation-militaire",
|
||||
"lpm": "loi-programmation-militaire",
|
||||
"montagne vivante": "ppl-montagne",
|
||||
"ppl montagne": "ppl-montagne",
|
||||
"patrimoine immobilier": "ppl-patrimoine-immobilier-etat",
|
||||
"pacte migration": "pjl-pacte-migration",
|
||||
"pacte europeen sur la migration": "pjl-pacte-migration",
|
||||
"protection des enfants": "pjl-protection-enfants",
|
||||
"cohesion republicaine": "pjl-cohesion-republicaine",
|
||||
"logement (jeanbrun)": "pjl-logement",
|
||||
"jeanbrun": "pjl-logement",
|
||||
"globe": "accord-globe",
|
||||
"entrisme": "ppl-entrisme",
|
||||
"separatisme": "pjl-separatisme",
|
||||
"nunez": "pjl-separatisme",
|
||||
"outrage a l'hymne": "ppl-outrage-hymne-drapeau",
|
||||
"code noir": "ppl-abrogation-code-noir",
|
||||
"mathiasin": "ppl-abrogation-code-noir",
|
||||
"adaptation du droit des outre-mer": "ppl-adaptation-droit-outre-mer",
|
||||
"emploi durable": "ppl-emploi-ultramarin",
|
||||
"ratenon": "ppl-emploi-ultramarin",
|
||||
"entites naturelles": "ppl-entites-naturelles",
|
||||
"devoir conjugal": "ppl-devoir-conjugal",
|
||||
"kazakhstan": "accord-kazakhstan-readmission",
|
||||
"colombie": "accord-colombie-extradition",
|
||||
"macf": "pjl-ratification-ordonnance-macf",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MentionTexte:
|
||||
"""Ce qu'un chapitre dit d'un texte donné."""
|
||||
|
||||
texte_id: str
|
||||
marqueurs: list[str] = field(default_factory=list)
|
||||
phrases: list[str] = field(default_factory=list)
|
||||
chapitre: str = ""
|
||||
section: str = ""
|
||||
# Vrai lorsque le titre de section nomme explicitement ce texte : la
|
||||
# section lui est alors consacrée, et sa prose peut servir de résumé.
|
||||
section_dediee: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LectureChapitres:
|
||||
mentions: dict[str, list[MentionTexte]] = field(default_factory=dict)
|
||||
|
||||
def marqueurs_de(self, texte_id: str) -> list[str]:
|
||||
vus: list[str] = []
|
||||
for mention in self.mentions.get(texte_id, []):
|
||||
for marqueur in mention.marqueurs:
|
||||
if marqueur not in vus:
|
||||
vus.append(marqueur)
|
||||
return vus
|
||||
|
||||
def resume_de(self, texte_id: str, *, phrases_max: int = 5) -> str | None:
|
||||
"""Résumé tiré des sections consacrées au texte, sinon `None`.
|
||||
|
||||
Aucune phrase n'est reformulée : ce sont celles du rapport, nettoyées
|
||||
de leur balisage et de leurs marqueurs de citation.
|
||||
"""
|
||||
dediees = [m for m in self.mentions.get(texte_id, []) if m.section_dediee]
|
||||
candidates = dediees or self.mentions.get(texte_id, [])
|
||||
|
||||
phrases: list[str] = []
|
||||
for mention in candidates:
|
||||
for phrase in mention.phrases:
|
||||
propre = nettoyer(phrase)
|
||||
if len(propre) > 40 and propre not in phrases:
|
||||
phrases.append(propre)
|
||||
if len(phrases) >= phrases_max:
|
||||
break
|
||||
if len(phrases) >= phrases_max:
|
||||
break
|
||||
|
||||
if not phrases:
|
||||
return None
|
||||
return " ".join(phrases[:phrases_max])
|
||||
|
||||
|
||||
def parser() -> LectureChapitres:
|
||||
"""Analyse les onze chapitres et rattache leurs mentions aux textes."""
|
||||
lecture = LectureChapitres()
|
||||
|
||||
for numero in range(11):
|
||||
fichier = chemins.chapitre(numero)
|
||||
if not fichier.exists():
|
||||
continue
|
||||
nom = f"sec{numero:02d}"
|
||||
|
||||
for section in decouper_sections(fichier.read_text(encoding="utf-8"), niveau_max=4):
|
||||
cibles_du_titre = _textes_designes(section.titre)
|
||||
|
||||
for paragraphe in _paragraphes(section.corps):
|
||||
for phrase in _phrases(paragraphe):
|
||||
cibles = _textes_designes(phrase) or cibles_du_titre
|
||||
if not cibles:
|
||||
continue
|
||||
marqueurs = [m.brut for m in extraire_marqueurs(phrase)]
|
||||
for cible in cibles:
|
||||
_ajouter(
|
||||
lecture,
|
||||
cible,
|
||||
marqueurs=marqueurs,
|
||||
phrase=phrase,
|
||||
chapitre=nom,
|
||||
section=section.titre,
|
||||
dediee=cible in cibles_du_titre,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"chapitres analysés",
|
||||
textes_mentionnes=len(lecture.mentions),
|
||||
mentions=sum(len(m) for m in lecture.mentions.values()),
|
||||
)
|
||||
return lecture
|
||||
|
||||
|
||||
def _ajouter(
|
||||
lecture: LectureChapitres,
|
||||
texte_id: str,
|
||||
*,
|
||||
marqueurs: list[str],
|
||||
phrase: str,
|
||||
chapitre: str,
|
||||
section: str,
|
||||
dediee: bool,
|
||||
) -> None:
|
||||
mentions = lecture.mentions.setdefault(texte_id, [])
|
||||
courante = next(
|
||||
(m for m in mentions if m.chapitre == chapitre and m.section == section), None
|
||||
)
|
||||
if courante is None:
|
||||
courante = MentionTexte(
|
||||
texte_id=texte_id, chapitre=chapitre, section=section, section_dediee=dediee
|
||||
)
|
||||
mentions.append(courante)
|
||||
|
||||
courante.section_dediee = courante.section_dediee or dediee
|
||||
courante.phrases.append(phrase)
|
||||
for marqueur in marqueurs:
|
||||
if marqueur not in courante.marqueurs:
|
||||
courante.marqueurs.append(marqueur)
|
||||
|
||||
|
||||
def _paragraphes(corps: str) -> list[str]:
|
||||
"""Paragraphes de prose, tableaux et listes exclus."""
|
||||
blocs: list[str] = []
|
||||
for bloc in corps.split("\n\n"):
|
||||
propre = bloc.strip()
|
||||
if not propre or propre.startswith(("|", "#", "- ", "* ", ">")):
|
||||
continue
|
||||
blocs.append(" ".join(propre.split()))
|
||||
return blocs
|
||||
|
||||
|
||||
def _phrases(paragraphe: str) -> list[str]:
|
||||
return [p for p in _FIN_DE_PHRASE.split(paragraphe) if p.strip()]
|
||||
|
||||
|
||||
def _textes_designes(fragment: str) -> list[str]:
|
||||
"""Identifiants des textes qu'un fragment nomme, par numéro ou nom d'usage."""
|
||||
trouves: list[str] = []
|
||||
|
||||
for numero in _NUMERO_LOI.findall(fragment):
|
||||
identifiant = f"loi-{numero}"
|
||||
if identifiant not in trouves:
|
||||
trouves.append(identifiant)
|
||||
|
||||
plat = sans_accents(fragment).lower()
|
||||
for nom, identifiant in NOMS_D_USAGE.items():
|
||||
if nom in plat and identifiant not in trouves:
|
||||
trouves.append(identifiant)
|
||||
|
||||
return trouves
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Résolution des marqueurs de citation du corpus.
|
||||
|
||||
Le rapport consolidé relie chacun de ses faits à sa source par un marqueur de
|
||||
la forme ``[^dim07-24^]``. La chaîne de résolution est ::
|
||||
|
||||
sec07.md : « … chlordécone [^dim07-24^] »
|
||||
→ rapport de dimension dim07, référence locale n° 24
|
||||
→ bloc sourcé ou note de bas de page portant ce numéro
|
||||
→ Source vérifiable (URL, éditeur, date, extrait verbatim)
|
||||
|
||||
Encore faut-il savoir ce que « référence locale n° 24 » désigne : le corpus
|
||||
emploie **six conventions de numérotation** selon la dimension, appliquées ici
|
||||
par ordre de priorité décroissante.
|
||||
|
||||
============= ========================================= =========================
|
||||
Convention Exemple Dimensions concernées
|
||||
============= ========================================= =========================
|
||||
marqueur ``Claim: … [^24^]`` dim01, dim02, dim04,
|
||||
de fin dim05, dim07, dim08
|
||||
en-tête ``**Claim 1.2** — …`` dim10, phase5
|
||||
de claim
|
||||
section ``### 1.4. LPM actualisée …`` dim10, dim11, dim12
|
||||
rang 1ᵉʳ claim du fichier = n° 1 dim03
|
||||
note ``[^1614^] assemblee-nationale.fr — …`` dim06, dim08, dim09
|
||||
section.rang section 2, 1ᵉʳ bloc → « 2.1 » dim11
|
||||
============= ========================================= =========================
|
||||
|
||||
Les indexer toutes fait passer la résolution de 55 % à 100 % des 332 marqueurs
|
||||
du rapport, dont 93 % avec un lien vérifiable. Les 7 % restants désignent une
|
||||
référence réelle mais dépourvue d'URL dans le corpus — presque toutes dans
|
||||
dim09, qui ne compte que cinq lignes contenant une adresse pour soixante-cinq
|
||||
notes ; `_rapprocher_notes_sans_lien` en récupère une partie via la
|
||||
bibliographie consolidée.
|
||||
|
||||
`insight` est le seul préfixe volontairement non résolu : la table de
|
||||
correspondance de la bibliographie indique qu'il ne porte « aucune source
|
||||
primaire propre ».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pipeline import chemins
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs import bibliographie, blocs_claim, notes_bas_page
|
||||
from pipeline.parseurs.blocs_claim import BlocClaim
|
||||
from pipeline.parseurs.dates_fr import sans_accents
|
||||
from pipeline.parseurs.markdown import Marqueur, extraire_marqueurs
|
||||
from pipeline.parseurs.notes_bas_page import Note
|
||||
|
||||
log = logger("parseur.citations")
|
||||
|
||||
DIMENSIONS_SANS_SOURCE = frozenset({"insight"})
|
||||
|
||||
# « ### 1.4. LPM actualisée … », « ## 3.1 Parcours … », « ## POINT 5 — … »
|
||||
_ENTETE_NUMEROTEE = re.compile(r"^#{2,4}\s+(?:POINT\s+)?([\d]+(?:\.[\d]+)*)\.?\s", re.IGNORECASE)
|
||||
_ENTETE_CLAIM = re.compile(r"^\s*(?:[-*]\s*)?\*{0,2}\s*Claim\s+([\d]+(?:\.[\d]+)*)", re.IGNORECASE)
|
||||
|
||||
# Mots trop courants pour distinguer deux références l'une de l'autre.
|
||||
_MOTS_VIDES = frozenset(
|
||||
"""a au aux avec ce ces dans de des du elle en et eux il ils je la le les leur
|
||||
lui ma mais me meme mes moi mon ne nos notre nous on ou par pas pour qu que qui
|
||||
sa se ses son sur ta te tes toi ton tu un une vos votre vous sont est etre plus
|
||||
loi projet proposition texte article articles n no ndeg juillet juin mai avril
|
||||
aout septembre octobre 2026 2025 fr com www http https org gouv""".split()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Resolution:
|
||||
"""Ce qu'un marqueur désigne, avec ou sans lien exploitable."""
|
||||
|
||||
marqueur: str
|
||||
source: Source | None = None
|
||||
libelle: str | None = None # texte de la référence, même sans URL
|
||||
origine: str = "" # « bloc », « note », « section », « rang »
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Repertoire:
|
||||
"""Index de résolution construit une fois pour tout le seed."""
|
||||
|
||||
entrees: dict[str, dict[str, Resolution]] = field(default_factory=dict)
|
||||
references: list[bibliographie.Reference] = field(default_factory=list)
|
||||
par_numero_loi: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
par_affaire_cc: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
par_document: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
blocs_par_dimension: dict[str, list[BlocClaim]] = field(default_factory=dict)
|
||||
correspondances: dict[str, set[int]] = field(default_factory=dict)
|
||||
rapprochements: int = 0
|
||||
|
||||
# Diagnostic d'import
|
||||
resolus_avec_lien: list[str] = field(default_factory=list)
|
||||
resolus_sans_lien: list[str] = field(default_factory=list)
|
||||
non_resolus: list[str] = field(default_factory=list)
|
||||
|
||||
# ── Résolution ───────────────────────────────────────────────────────────
|
||||
def _entree(self, marqueur: Marqueur) -> Resolution | None:
|
||||
if not marqueur.dimension or marqueur.dimension in DIMENSIONS_SANS_SOURCE:
|
||||
return None
|
||||
return self.entrees.get(marqueur.dimension, {}).get(marqueur.numero)
|
||||
|
||||
def resoudre(self, marqueur: Marqueur | str) -> Source | None:
|
||||
"""Rend la source d'un marqueur, ou `None` s'il n'en désigne aucune."""
|
||||
marqueur = _normaliser(marqueur)
|
||||
if marqueur is None:
|
||||
return None
|
||||
|
||||
entree = self._entree(marqueur)
|
||||
if entree is None:
|
||||
if marqueur.dimension not in DIMENSIONS_SANS_SOURCE:
|
||||
self.non_resolus.append(marqueur.brut)
|
||||
return None
|
||||
|
||||
if entree.source is not None:
|
||||
self.resolus_avec_lien.append(marqueur.brut)
|
||||
return entree.source
|
||||
|
||||
self.resolus_sans_lien.append(marqueur.brut)
|
||||
return None
|
||||
|
||||
def libelle(self, marqueur: Marqueur | str) -> str | None:
|
||||
"""Texte de la référence, y compris lorsqu'elle n'a pas d'URL."""
|
||||
marqueur = _normaliser(marqueur)
|
||||
entree = self._entree(marqueur) if marqueur else None
|
||||
return entree.libelle if entree else None
|
||||
|
||||
def resoudre_plusieurs(self, marqueurs: list[Marqueur] | list[str]) -> list[Source]:
|
||||
"""Résout une liste de marqueurs, en écartant les doublons d'URL."""
|
||||
sources: list[Source] = []
|
||||
vues: set[str] = set()
|
||||
for marqueur in marqueurs:
|
||||
source = self.resoudre(marqueur)
|
||||
if source and source.url not in vues:
|
||||
vues.add(source.url)
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
def references_du_texte(
|
||||
self,
|
||||
*,
|
||||
numero_loi: str | None = None,
|
||||
affaires: list[str] | None = None,
|
||||
documents: list[str] | None = None,
|
||||
) -> list[Source]:
|
||||
"""Sources bibliographiques pour un numéro de loi, une affaire ou une cote.
|
||||
|
||||
La recherche par cote de document parlementaire est le seul recours
|
||||
pour les textes qui n'ont pas de numéro de loi : une proposition
|
||||
déposée et non examinée n'est identifiée que par sa cote, « AN n° 3025 ».
|
||||
"""
|
||||
sources: list[Source] = []
|
||||
vues: set[str] = set()
|
||||
|
||||
candidates: list[bibliographie.Reference] = []
|
||||
if numero_loi:
|
||||
candidates.extend(self.par_numero_loi.get(numero_loi, []))
|
||||
for affaire in affaires or []:
|
||||
candidates.extend(self.par_affaire_cc.get(affaire, []))
|
||||
for document in documents or []:
|
||||
candidates.extend(self.par_document.get(document, []))
|
||||
|
||||
for reference in candidates:
|
||||
source = reference.en_source()
|
||||
if source and source.url not in vues:
|
||||
vues.add(source.url)
|
||||
sources.append(source)
|
||||
|
||||
return sources
|
||||
|
||||
@property
|
||||
def taux_de_resolution(self) -> float:
|
||||
"""Part des marqueurs qui aboutissent à une source ou à une référence."""
|
||||
total = len(self.resolus_avec_lien) + len(self.resolus_sans_lien) + len(self.non_resolus)
|
||||
if not total:
|
||||
return 0.0
|
||||
return (len(self.resolus_avec_lien) + len(self.resolus_sans_lien)) / total
|
||||
|
||||
def remettre_a_zero_diagnostic(self) -> None:
|
||||
self.resolus_avec_lien.clear()
|
||||
self.resolus_sans_lien.clear()
|
||||
self.non_resolus.clear()
|
||||
|
||||
|
||||
def _normaliser(marqueur: Marqueur | str) -> Marqueur | None:
|
||||
if isinstance(marqueur, Marqueur):
|
||||
return marqueur
|
||||
trouves = extraire_marqueurs(f"[^{marqueur}^]")
|
||||
return trouves[0] if trouves else None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Construction de l'index
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def construire() -> Repertoire:
|
||||
"""Charge et indexe l'ensemble des sources du corpus."""
|
||||
repertoire = Repertoire()
|
||||
|
||||
for numero in range(1, 13):
|
||||
dimension = f"dim{numero:02d}"
|
||||
markdown = chemins.dimension(numero).read_text(encoding="utf-8")
|
||||
blocs = blocs_claim.parser(markdown, dimension)
|
||||
notes = notes_bas_page.parser(markdown, dimension)
|
||||
repertoire.blocs_par_dimension[dimension] = blocs
|
||||
repertoire.entrees[dimension] = _indexer(markdown, blocs, notes)
|
||||
|
||||
phase5 = chemins.PHASE5_VALIDATION.read_text(encoding="utf-8")
|
||||
blocs_phase5 = blocs_claim.parser(phase5, "phase5")
|
||||
repertoire.blocs_par_dimension["phase5"] = blocs_phase5
|
||||
repertoire.entrees["phase5"] = _indexer(phase5, blocs_phase5, [])
|
||||
|
||||
markdown_biblio = chemins.BIBLIOGRAPHIE.read_text(encoding="utf-8")
|
||||
repertoire.references = bibliographie.parser(markdown_biblio)
|
||||
repertoire.par_numero_loi = bibliographie.indexer_par_numero(repertoire.references)
|
||||
repertoire.par_affaire_cc = bibliographie.indexer_par_affaire(repertoire.references)
|
||||
repertoire.par_document = bibliographie.indexer_par_document(repertoire.references)
|
||||
repertoire.correspondances = bibliographie.lire_table_de_correspondance(markdown_biblio)
|
||||
|
||||
repertoire.rapprochements = _rapprocher_notes_sans_lien(repertoire)
|
||||
|
||||
avec_lien = sum(
|
||||
1 for entrees in repertoire.entrees.values() for e in entrees.values() if e.source
|
||||
)
|
||||
log.info(
|
||||
"répertoire de citations construit",
|
||||
dimensions=len(repertoire.entrees),
|
||||
entrees=sum(len(e) for e in repertoire.entrees.values()),
|
||||
avec_lien=avec_lien,
|
||||
references=len(repertoire.references),
|
||||
)
|
||||
return repertoire
|
||||
|
||||
|
||||
def _indexer(
|
||||
markdown: str, blocs: list[BlocClaim], notes: list[Note]
|
||||
) -> dict[str, Resolution]:
|
||||
"""Construit l'index d'une dimension selon les cinq conventions du corpus.
|
||||
|
||||
L'ordre d'insertion vaut priorité : une convention n'écrase jamais une
|
||||
entrée déjà posée par une convention plus explicite.
|
||||
"""
|
||||
index: dict[str, Resolution] = {}
|
||||
|
||||
def poser(cle: str, resolution: Resolution) -> None:
|
||||
if cle and cle not in index:
|
||||
index[cle] = resolution
|
||||
|
||||
# 1. Marqueur de fin de claim — la convention la plus explicite.
|
||||
for bloc in blocs:
|
||||
for marqueur in bloc.marqueurs:
|
||||
poser(marqueur, _depuis_bloc(bloc, "bloc"))
|
||||
|
||||
# 2. Numéro porté par l'en-tête du claim (« **Claim 1.2** — … »).
|
||||
for bloc, numero in _numeros_d_entete(markdown, blocs):
|
||||
poser(numero, _depuis_bloc(bloc, "en-tête"))
|
||||
|
||||
# 3. Numéro de section : le premier bloc sourcé de la section en tient lieu.
|
||||
for numero, bloc in _blocs_par_section(markdown, blocs).items():
|
||||
poser(numero, _depuis_bloc(bloc, "section"))
|
||||
|
||||
# 4. Rang du claim dans le fichier (dim03 ne numérote rien d'autre).
|
||||
for rang, bloc in enumerate(blocs, start=1):
|
||||
poser(str(rang), _depuis_bloc(bloc, "rang"))
|
||||
|
||||
# 5. Notes de bas de page — souvent sans URL, mais toujours un libellé.
|
||||
for note in notes:
|
||||
source = note.en_source()
|
||||
if note.numero in index and index[note.numero].source is not None:
|
||||
continue
|
||||
index[note.numero] = Resolution(
|
||||
marqueur=note.numero,
|
||||
source=source,
|
||||
libelle=note.texte or None,
|
||||
origine="note",
|
||||
)
|
||||
|
||||
# 6. Composition « section.rang » : dim11 numérote ses sections mais pas ses
|
||||
# affirmations, et le rapport la cite en `[^dim11-2.1^]` — soit le
|
||||
# premier bloc de la section 2. Convention de dernier recours.
|
||||
for numero_compose, bloc in _blocs_par_section_et_rang(markdown, blocs).items():
|
||||
poser(numero_compose, _depuis_bloc(bloc, "section.rang"))
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def _blocs_par_section_et_rang(
|
||||
markdown: str, blocs: list[BlocClaim]
|
||||
) -> dict[str, BlocClaim]:
|
||||
"""Numérote les blocs d'une section : section 2, 1ᵉʳ bloc → clé « 2.1 »."""
|
||||
lignes = markdown.splitlines()
|
||||
frontieres = [
|
||||
(numero, entete.group(1))
|
||||
for numero, ligne in enumerate(lignes, start=1)
|
||||
if (entete := _ENTETE_NUMEROTEE.match(ligne))
|
||||
]
|
||||
if not frontieres:
|
||||
return {}
|
||||
|
||||
composes: dict[str, BlocClaim] = {}
|
||||
for index, (debut, section) in enumerate(frontieres):
|
||||
if "." in section: # déjà une sous-section, elle est indexée telle quelle
|
||||
continue
|
||||
fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
|
||||
for rang, bloc in enumerate(
|
||||
(b for b in blocs if debut < b.ligne < fin), start=1
|
||||
):
|
||||
composes.setdefault(f"{section}.{rang}", bloc)
|
||||
|
||||
return composes
|
||||
|
||||
|
||||
def _rapprocher_notes_sans_lien(repertoire: Repertoire) -> int:
|
||||
"""Donne une URL aux références qui n'en portent pas dans leur dimension.
|
||||
|
||||
dim09 — la dimension « associations », centrale pour la mission — ne compte
|
||||
que 5 lignes contenant une URL pour 65 notes. Ses sources existent pourtant :
|
||||
la bibliographie consolidée les liste toutes, et la table de correspondance
|
||||
dit lesquelles lui appartiennent.
|
||||
|
||||
Le rapprochement est délibérément prudent : il ne cherche que parmi les
|
||||
références attribuées à la dimension, exige au moins trois mots significatifs
|
||||
communs et un recouvrement d'au moins deux tiers du libellé de la note, et
|
||||
n'accepte le résultat que s'il est **strictement meilleur** que le suivant.
|
||||
Une correspondance ambiguë est refusée plutôt que devinée.
|
||||
"""
|
||||
par_numero = {reference.numero: reference for reference in repertoire.references}
|
||||
rapproches = 0
|
||||
|
||||
for dimension, entrees in repertoire.entrees.items():
|
||||
numeros_autorises = repertoire.correspondances.get(dimension)
|
||||
if not numeros_autorises:
|
||||
continue
|
||||
|
||||
candidates = [
|
||||
(reference, _mots_significatifs(f"{reference.editeur or ''} {reference.titre}"))
|
||||
for numero in sorted(numeros_autorises)
|
||||
if (reference := par_numero.get(numero)) and reference.url
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
for resolution in entrees.values():
|
||||
if resolution.source is not None or not resolution.libelle:
|
||||
continue
|
||||
|
||||
reference = _meilleure_correspondance(resolution.libelle, candidates)
|
||||
if reference is None:
|
||||
continue
|
||||
|
||||
source = reference.en_source()
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
resolution.source = Source(
|
||||
url=source.url,
|
||||
titre=resolution.libelle[:280],
|
||||
editeur=reference.editeur,
|
||||
date_publication=source.date_publication,
|
||||
confiance=Confiance.MEDIUM,
|
||||
marqueur=f"{dimension}-{resolution.marqueur or '?'}",
|
||||
fichier_origine=f"research/lois-2026_{dimension}.md "
|
||||
f"(lien repris de la bibliographie, réf. {reference.numero})",
|
||||
)
|
||||
resolution.origine = "note→bibliographie"
|
||||
rapproches += 1
|
||||
|
||||
log.info("notes sans lien rapprochées de la bibliographie", rapprochements=rapproches)
|
||||
return rapproches
|
||||
|
||||
|
||||
def _mots_significatifs(texte: str) -> set[str]:
|
||||
plat = sans_accents(texte).lower()
|
||||
return {
|
||||
mot
|
||||
for mot in re.split(r"[^a-z0-9]+", plat)
|
||||
if len(mot) > 2 and mot not in _MOTS_VIDES
|
||||
}
|
||||
|
||||
|
||||
def _meilleure_correspondance(
|
||||
libelle: str, candidates: list[tuple[bibliographie.Reference, set[str]]]
|
||||
) -> bibliographie.Reference | None:
|
||||
mots_note = _mots_significatifs(libelle)
|
||||
if len(mots_note) < 3:
|
||||
return None
|
||||
|
||||
scores: list[tuple[float, int, bibliographie.Reference]] = []
|
||||
for reference, mots_reference in candidates:
|
||||
communs = mots_note & mots_reference
|
||||
if len(communs) < 3:
|
||||
continue
|
||||
scores.append((len(communs) / len(mots_note), len(communs), reference))
|
||||
|
||||
if not scores:
|
||||
return None
|
||||
|
||||
scores.sort(key=lambda triplet: (-triplet[0], -triplet[1]))
|
||||
meilleur = scores[0]
|
||||
if meilleur[0] < 0.66:
|
||||
return None
|
||||
# Ambiguïté : deux références également plausibles, on s'abstient.
|
||||
if len(scores) > 1 and abs(scores[1][0] - meilleur[0]) < 1e-9:
|
||||
return None
|
||||
|
||||
return meilleur[2]
|
||||
|
||||
|
||||
def _depuis_bloc(bloc: BlocClaim, origine: str) -> Resolution:
|
||||
sources = bloc.en_sources()
|
||||
return Resolution(
|
||||
marqueur=bloc.marqueurs[0] if bloc.marqueurs else "",
|
||||
source=sources[0] if sources else None,
|
||||
libelle=bloc.affirmation or None,
|
||||
origine=origine,
|
||||
)
|
||||
|
||||
|
||||
def _numeros_d_entete(markdown: str, blocs: list[BlocClaim]) -> list[tuple[BlocClaim, str]]:
|
||||
"""Associe chaque bloc au numéro figurant dans sa ligne d'ouverture."""
|
||||
par_ligne = {bloc.ligne: bloc for bloc in blocs}
|
||||
associations: list[tuple[BlocClaim, str]] = []
|
||||
|
||||
for numero_ligne, ligne in enumerate(markdown.splitlines(), start=1):
|
||||
entete = _ENTETE_CLAIM.match(ligne)
|
||||
if entete and numero_ligne in par_ligne:
|
||||
associations.append((par_ligne[numero_ligne], entete.group(1)))
|
||||
|
||||
return associations
|
||||
|
||||
|
||||
def _blocs_par_section(markdown: str, blocs: list[BlocClaim]) -> dict[str, BlocClaim]:
|
||||
"""Associe un numéro de section au premier bloc sourcé qu'elle contient.
|
||||
|
||||
C'est la seule façon de résoudre ``[^dim11-1.4^]`` : dim11 ne numérote pas
|
||||
ses affirmations, elle numérote ses sections (« ### 1.4. LPM actualisée »).
|
||||
"""
|
||||
lignes = markdown.splitlines()
|
||||
frontieres: list[tuple[int, str]] = []
|
||||
|
||||
for numero_ligne, ligne in enumerate(lignes, start=1):
|
||||
if entete := _ENTETE_NUMEROTEE.match(ligne):
|
||||
frontieres.append((numero_ligne, entete.group(1)))
|
||||
|
||||
if not frontieres:
|
||||
return {}
|
||||
|
||||
par_section: dict[str, BlocClaim] = {}
|
||||
for index, (debut, numero) in enumerate(frontieres):
|
||||
fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
|
||||
candidats = [b for b in blocs if debut < b.ligne < fin]
|
||||
# Un bloc avec URL vaut mieux qu'un bloc sans, à section égale.
|
||||
avec_lien = next((b for b in candidats if b.urls), None)
|
||||
retenu = avec_lien or (candidats[0] if candidats else None)
|
||||
if retenu is not None and numero not in par_section:
|
||||
par_section[numero] = retenu
|
||||
|
||||
return par_section
|
||||
@@ -13,6 +13,9 @@ from dataclasses import dataclass, field
|
||||
# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^]
|
||||
MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]")
|
||||
|
||||
# Le marqueur avec l'espace qui le précède, pour un retrait sans cicatrice.
|
||||
_MARQUEUR_ET_ESPACE = re.compile(r"[ \t]*(?:\[\^[a-zA-Z0-9]+(?:-[0-9.]+)?\^\])+")
|
||||
|
||||
_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL)
|
||||
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
_CODE = re.compile(r"`([^`]+)`")
|
||||
@@ -114,7 +117,11 @@ def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> str:
|
||||
resultat = _LIEN_MD.sub(r"\1", resultat)
|
||||
resultat = _EMPHASE.sub(r"\1", resultat)
|
||||
if not garder_marqueurs:
|
||||
resultat = MOTIF_MARQUEUR.sub("", resultat)
|
||||
# L'espace qui précède le marqueur est consommée avec lui, sinon le
|
||||
# retrait laisse « … dans les DROM . ». On ne touche surtout pas aux
|
||||
# autres espaces : la typographie française en veut une avant « ; »,
|
||||
# « : », « ! », « ? » et à l'intérieur des guillemets « … ».
|
||||
resultat = _MARQUEUR_ET_ESPACE.sub("", resultat)
|
||||
return " ".join(resultat.split()).strip(" ;,")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Lecture des listes de notes en fin de rapport de dimension.
|
||||
|
||||
Cinq dimensions (dim06, dim08, dim09, dim11, dim12) ne portent pas — ou peu —
|
||||
de blocs sourcés : leurs références vivent dans une liste finale du type ::
|
||||
|
||||
[^1141^] senat.fr — Texte n° 890, projet de loi cohésion républicaine
|
||||
(09/07/2026) — https://www.senat.fr/leg/pjl25-890.html
|
||||
[^44^] diplomatie.gouv.fr, 16/04/2026.
|
||||
|
||||
Toutes ne portent pas d'URL : sur les 258 notes du corpus, 150 en sont
|
||||
dépourvues. Deux cas sont alors distingués :
|
||||
|
||||
- la note cite un identifiant Légifrance (``JORFTEXT…``, ``JORFARTI…``) : l'URL
|
||||
canonique est reconstruite, ce qui n'invente rien puisque le schéma d'URL est
|
||||
fixe et documenté ;
|
||||
- sinon la référence est conservée telle quelle, sans URL, et comptée comme
|
||||
« non résolue » dans le compte-rendu d'import. Elle pourra être rapprochée de
|
||||
la bibliographie consolidée, qui, elle, est intégralement liée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs.dates_fr import lire_date
|
||||
from pipeline.parseurs.markdown import nettoyer
|
||||
|
||||
ANNEE_CORPUS = 2026
|
||||
|
||||
_LIGNE_NOTE = re.compile(r"^\[\^([0-9.]+)\^\]\s+(.*)$")
|
||||
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||||
_IDENTIFIANT_LEGIFRANCE = re.compile(r"\b(JORFTEXT\d{12}|JORFARTI\d{12}|LEGITEXT\d{12})\b")
|
||||
_DATE_PARENTHESES = re.compile(r"\(([^)]*\d{4}[^)]*)\)")
|
||||
|
||||
# Schéma d'URL documenté de Légifrance : un identifiant JORF s'ouvre toujours à
|
||||
# cette adresse. Reconstruire le lien n'ajoute aucune information nouvelle.
|
||||
_GABARIT_LEGIFRANCE = "https://www.legifrance.gouv.fr/jorf/id/{identifiant}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Note:
|
||||
"""Une référence numérotée en fin de rapport de dimension."""
|
||||
|
||||
dimension: str
|
||||
numero: str
|
||||
texte: str
|
||||
url: str | None = None
|
||||
editeur: str | None = None
|
||||
date_publication: date | None = None
|
||||
url_reconstruite: bool = False
|
||||
|
||||
@property
|
||||
def marqueur(self) -> str:
|
||||
return f"{self.dimension}-{self.numero}"
|
||||
|
||||
def en_source(self) -> Source | None:
|
||||
"""Convertit la note en source, ou `None` si elle n'a pas d'URL."""
|
||||
if not self.url:
|
||||
return None
|
||||
return Source(
|
||||
url=self.url,
|
||||
titre=self.texte[:280] or None,
|
||||
editeur=self.editeur,
|
||||
date_publication=self.date_publication,
|
||||
confiance=Confiance.MEDIUM,
|
||||
marqueur=self.marqueur,
|
||||
fichier_origine=f"research/lois-2026_{self.dimension}.md",
|
||||
)
|
||||
|
||||
|
||||
def parser(markdown: str, dimension: str) -> list[Note]:
|
||||
"""Extrait toutes les notes numérotées d'un rapport de dimension."""
|
||||
notes: list[Note] = []
|
||||
|
||||
for ligne in markdown.splitlines():
|
||||
correspondance = _LIGNE_NOTE.match(ligne.strip())
|
||||
if not correspondance:
|
||||
continue
|
||||
notes.append(_lire_note(correspondance.group(1), correspondance.group(2), dimension))
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
def _lire_note(numero: str, corps: str, dimension: str) -> Note:
|
||||
url: str | None = None
|
||||
reconstruite = False
|
||||
|
||||
if trouvee := _URL.search(corps):
|
||||
url = trouvee.group(0).rstrip(".,;)")
|
||||
elif identifiant := _IDENTIFIANT_LEGIFRANCE.search(corps):
|
||||
url = _GABARIT_LEGIFRANCE.format(identifiant=identifiant.group(1))
|
||||
reconstruite = True
|
||||
|
||||
return Note(
|
||||
dimension=dimension,
|
||||
numero=numero,
|
||||
texte=nettoyer(_sans_url(corps)),
|
||||
url=url,
|
||||
editeur=_lire_editeur(corps),
|
||||
date_publication=_lire_date_note(corps),
|
||||
url_reconstruite=reconstruite,
|
||||
)
|
||||
|
||||
|
||||
def _sans_url(corps: str) -> str:
|
||||
return _URL.sub("", corps).strip(" —–-:.,")
|
||||
|
||||
|
||||
def _lire_editeur(corps: str) -> str | None:
|
||||
"""L'éditeur ouvre la note, avant le tiret cadratin ou la première virgule."""
|
||||
debut = _sans_url(corps)
|
||||
for separateur in ("—", "–", " : ", ","):
|
||||
if separateur in debut:
|
||||
debut = debut.split(separateur, 1)[0]
|
||||
break
|
||||
debut = nettoyer(debut).strip(" .")
|
||||
return debut[:120] or None
|
||||
|
||||
|
||||
def _lire_date_note(corps: str) -> date | None:
|
||||
"""Date entre parenthèses en priorité, sinon première date du corps."""
|
||||
for parenthese in _DATE_PARENTHESES.findall(corps):
|
||||
if lue := lire_date(parenthese, annee_defaut=ANNEE_CORPUS):
|
||||
return lue
|
||||
return lire_date(_sans_url(corps), annee_defaut=ANNEE_CORPUS)
|
||||
@@ -228,6 +228,11 @@ class ResultatSeed:
|
||||
|
||||
textes: list[Texte] = field(default_factory=list)
|
||||
avertissements: list[str] = field(default_factory=list)
|
||||
# Marqueurs de citation portés par la ligne d'annexe de chaque texte.
|
||||
# Les lignes éclatées transmettent les leurs aux deux textes issus d'elles :
|
||||
# sans cela, l'accord « plateforme centrale » MACF n'hériterait d'aucune
|
||||
# source, alors que sa ligne d'origine en cite trois.
|
||||
marqueurs: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.textes)
|
||||
@@ -249,7 +254,9 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
else:
|
||||
for ligne in tableau_a.lignes:
|
||||
try:
|
||||
resultat.textes.append(_lire_loi_promulguee(ligne))
|
||||
texte = _lire_loi_promulguee(ligne)
|
||||
resultat.textes.append(texte)
|
||||
resultat.marqueurs[texte.id] = _marqueurs_de_ligne(ligne)
|
||||
except Exception as erreur: # noqa: BLE001 — on continue le lot
|
||||
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
|
||||
|
||||
@@ -258,7 +265,11 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
else:
|
||||
for ligne in tableau_b.lignes:
|
||||
try:
|
||||
resultat.textes.extend(_lire_texte_en_cours(ligne))
|
||||
issus = _lire_texte_en_cours(ligne)
|
||||
marqueurs = _marqueurs_de_ligne(ligne)
|
||||
resultat.textes.extend(issus)
|
||||
for texte in issus:
|
||||
resultat.marqueurs[texte.id] = list(marqueurs)
|
||||
except Exception as erreur: # noqa: BLE001
|
||||
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
|
||||
|
||||
@@ -272,6 +283,16 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
return resultat
|
||||
|
||||
|
||||
def _marqueurs_de_ligne(ligne: dict[str, str]) -> list[str]:
|
||||
"""Marqueurs de citation portés par une ligne, toutes colonnes confondues."""
|
||||
marqueurs: list[str] = []
|
||||
for cellule in ligne.values():
|
||||
for marqueur in extraire_marqueurs(cellule):
|
||||
if marqueur.brut not in marqueurs:
|
||||
marqueurs.append(marqueur.brut)
|
||||
return marqueurs
|
||||
|
||||
|
||||
def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
|
||||
for tableau in tableaux:
|
||||
if tableau.entetes and tableau.entetes[0] == premiere_colonne:
|
||||
|
||||
Reference in New Issue
Block a user