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>
218 lines
7.4 KiB
Python
218 lines
7.4 KiB
Python
"""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
|