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:
Cyber Mawonaj
2026-07-25 19:43:03 -04:00
co-authored by Claude Opus 5
parent c36b8dc631
commit c28243cf87
12 changed files with 2108 additions and 3 deletions
+91
View File
@@ -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()
+234
View File
@@ -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
+217
View File
@@ -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
+222
View File
@@ -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"\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
+460
View File
@@ -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
+8 -1
View File
@@ -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(" ;,")
+128
View File
@@ -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)
+23 -2
View File
@@ -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: