Files
Cyber MawonajandClaude Opus 5 c28243cf87 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>
2026-07-25 19:43:03 -04:00

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