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
+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