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>
461 lines
19 KiB
Python
461 lines
19 KiB
Python
"""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
|