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>
188 lines
6.3 KiB
Python
188 lines
6.3 KiB
Python
"""Briques de lecture du markdown du corpus.
|
|
|
|
Trois besoins récurrents : découper les tableaux, retirer le balisage d'emphase
|
|
sans perdre le texte, et récolter les marqueurs de citation `[^dim07-24^]` qui
|
|
relient un fait de chapitre à sa source dans un rapport de dimension.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^]
|
|
MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]")
|
|
|
|
# Le marqueur avec l'espace qui le précède, pour un retrait sans cicatrice.
|
|
_MARQUEUR_ET_ESPACE = re.compile(r"[ \t]*(?:\[\^[a-zA-Z0-9]+(?:-[0-9.]+)?\^\])+")
|
|
|
|
_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL)
|
|
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
_CODE = re.compile(r"`([^`]+)`")
|
|
_SEPARATEUR_TABLEAU = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Tableau:
|
|
"""Un tableau markdown, avec le titre de section qui le précède."""
|
|
|
|
entetes: list[str]
|
|
lignes: list[dict[str, str]]
|
|
section: str = ""
|
|
ligne_debut: int = 0
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.lignes)
|
|
|
|
def colonne(self, prefixe: str) -> str | None:
|
|
"""Retrouve un nom d'en-tête par son début, insensible à la casse."""
|
|
prefixe = prefixe.lower()
|
|
for entete in self.entetes:
|
|
if entete.lower().startswith(prefixe):
|
|
return entete
|
|
return None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Marqueur:
|
|
"""Une référence `[^dimXX-N^]` rencontrée dans un texte."""
|
|
|
|
brut: str # « dim07-24 »
|
|
dimension: str # « dim07 », « phase5 », « insight », ou « ref »
|
|
numero: str # « 24 », « 3.1 »
|
|
|
|
@property
|
|
def cle(self) -> str:
|
|
return f"{self.dimension}-{self.numero}" if self.numero else self.dimension
|
|
|
|
|
|
def extraire_tableaux(markdown: str) -> list[Tableau]:
|
|
"""Découpe tous les tableaux d'un document markdown.
|
|
|
|
Une ligne d'en-tête suivie d'une ligne de séparation ouvre un tableau ; la
|
|
première ligne qui ne commence pas par `|` le referme.
|
|
"""
|
|
lignes = markdown.splitlines()
|
|
tableaux: list[Tableau] = []
|
|
section = ""
|
|
i = 0
|
|
|
|
while i < len(lignes):
|
|
ligne = lignes[i]
|
|
|
|
if ligne.lstrip().startswith("#"):
|
|
section = ligne.lstrip("#").strip()
|
|
i += 1
|
|
continue
|
|
|
|
est_ligne_tableau = ligne.strip().startswith("|")
|
|
separateur_suivant = i + 1 < len(lignes) and _SEPARATEUR_TABLEAU.match(lignes[i + 1])
|
|
|
|
if est_ligne_tableau and separateur_suivant:
|
|
entetes = _decouper_ligne(ligne)
|
|
corps: list[dict[str, str]] = []
|
|
j = i + 2
|
|
while j < len(lignes) and lignes[j].strip().startswith("|"):
|
|
cellules = _decouper_ligne(lignes[j])
|
|
if any(c for c in cellules):
|
|
# Tolère les lignes plus courtes ou plus longues que l'en-tête.
|
|
cellules += [""] * (len(entetes) - len(cellules))
|
|
corps.append(dict(zip(entetes, cellules[: len(entetes)], strict=True)))
|
|
j += 1
|
|
tableaux.append(
|
|
Tableau(entetes=entetes, lignes=corps, section=section, ligne_debut=i + 1)
|
|
)
|
|
i = j
|
|
continue
|
|
|
|
i += 1
|
|
|
|
return tableaux
|
|
|
|
|
|
def _decouper_ligne(ligne: str) -> list[str]:
|
|
"""Découpe une ligne de tableau en cellules, en respectant les `\\|` échappés."""
|
|
interne = ligne.strip()
|
|
if interne.startswith("|"):
|
|
interne = interne[1:]
|
|
if interne.endswith("|"):
|
|
interne = interne[:-1]
|
|
cellules = re.split(r"(?<!\\)\|", interne)
|
|
return [c.replace(r"\|", "|").strip() for c in cellules]
|
|
|
|
|
|
def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> str:
|
|
"""Retire le balisage d'emphase, les liens et les marqueurs de citation."""
|
|
resultat = _CODE.sub(r"\1", texte)
|
|
resultat = _LIEN_MD.sub(r"\1", resultat)
|
|
resultat = _EMPHASE.sub(r"\1", resultat)
|
|
if not garder_marqueurs:
|
|
# L'espace qui précède le marqueur est consommée avec lui, sinon le
|
|
# retrait laisse « … dans les DROM . ». On ne touche surtout pas aux
|
|
# autres espaces : la typographie française en veut une avant « ; »,
|
|
# « : », « ! », « ? » et à l'intérieur des guillemets « … ».
|
|
resultat = _MARQUEUR_ET_ESPACE.sub("", resultat)
|
|
return " ".join(resultat.split()).strip(" ;,")
|
|
|
|
|
|
def extraire_marqueurs(texte: str) -> list[Marqueur]:
|
|
"""Récolte les marqueurs de citation d'un fragment, sans doublon."""
|
|
marqueurs: list[Marqueur] = []
|
|
vus: set[str] = set()
|
|
|
|
for brut in MOTIF_MARQUEUR.findall(texte):
|
|
if brut in vus:
|
|
continue
|
|
vus.add(brut)
|
|
if "-" in brut:
|
|
dimension, numero = brut.split("-", 1)
|
|
elif brut.isdigit():
|
|
# Marqueur local à un fichier de dimension : [^24^]
|
|
dimension, numero = "", brut
|
|
else:
|
|
dimension, numero = brut, ""
|
|
marqueurs.append(Marqueur(brut=brut, dimension=dimension, numero=numero))
|
|
|
|
return marqueurs
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Section:
|
|
"""Un bloc de texte délimité par un titre markdown."""
|
|
|
|
niveau: int
|
|
titre: str
|
|
corps: str
|
|
ligne_debut: int
|
|
sous_sections: list[Section] = field(default_factory=list)
|
|
|
|
|
|
def decouper_sections(markdown: str, *, niveau_max: int = 3) -> list[Section]:
|
|
"""Découpe un document en sections à plat, jusqu'au niveau de titre demandé."""
|
|
lignes = markdown.splitlines()
|
|
sections: list[Section] = []
|
|
courante: Section | None = None
|
|
tampon: list[str] = []
|
|
|
|
for numero, ligne in enumerate(lignes, start=1):
|
|
entete = re.match(r"^(#{1,6})\s+(.*)$", ligne)
|
|
if entete and len(entete.group(1)) <= niveau_max:
|
|
if courante:
|
|
courante.corps = "\n".join(tampon).strip()
|
|
sections.append(courante)
|
|
courante = Section(
|
|
niveau=len(entete.group(1)),
|
|
titre=entete.group(2).strip(),
|
|
corps="",
|
|
ligne_debut=numero,
|
|
)
|
|
tampon = []
|
|
else:
|
|
tampon.append(ligne)
|
|
|
|
if courante:
|
|
courante.corps = "\n".join(tampon).strip()
|
|
sections.append(courante)
|
|
|
|
return sections
|