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