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