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>
223 lines
8.3 KiB
Python
223 lines
8.3 KiB
Python
"""Lecture des chapitres du rapport consolidé (`_sec00.md` → `_sec10.md`).
|
|
|
|
Les chapitres sont la seule partie rédigée du corpus : c'est d'eux que
|
|
proviennent les résumés et les points clés. Ils désignent les textes de deux
|
|
façons — par numéro (« la loi n° 2026-403 du 26 mai ») et par nom d'usage
|
|
(« la loi "Riposte" », « l'aide à mourir ») — et sèment des marqueurs de
|
|
citation à chaque affirmation.
|
|
|
|
Le rattachement se fait **phrase par phrase** : les marqueurs d'une phrase vont
|
|
au texte que cette phrase nomme, pas à tous les textes du paragraphe. Sans quoi
|
|
un paragraphe traitant de six lois attribuerait ses vingt sources aux six.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
from pipeline import chemins
|
|
from pipeline.journal import logger
|
|
from pipeline.parseurs.dates_fr import sans_accents
|
|
from pipeline.parseurs.markdown import decouper_sections, extraire_marqueurs, nettoyer
|
|
|
|
log = logger("parseur.chapitres")
|
|
|
|
# « loi n° 2026-403 », « loi organique n° 2026-410 », « n° 2026-491 du 12 juin ».
|
|
# La négation finale écarte les numéros d'affaire du Conseil constitutionnel :
|
|
# sans elle, « déclarée conforme par la décision n° 2026-903 DC, puis promulguée
|
|
# le 26 mai » désignerait une loi « 2026-903 » qui n'existe pas, et la phrase —
|
|
# avec ses sources — serait volée à la loi n° 2026-403 dont elle parle.
|
|
_NUMERO_LOI = re.compile(r"n°\s*(\d{4}-\d{1,4})\b(?!\s*DC)")
|
|
# Découpage en phrases : point suivi d'une espace et d'une majuscule ou d'un
|
|
# guillemet ouvrant. Les abréviations du corpus (« n° », « art. », « déc. »,
|
|
# « juill. ») sont protégées par l'exigence de majuscule qui suit.
|
|
_FIN_DE_PHRASE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÀÂÉÈÊÎÔÙÜ«])")
|
|
|
|
# Noms d'usage employés par les chapitres pour désigner un texte non promulgué.
|
|
# La clé est cherchée sans accent ni casse ; la valeur est l'identifiant posé
|
|
# par le parseur de l'annexe.
|
|
NOMS_D_USAGE: dict[str, str] = {
|
|
"riposte": "loi-riposte",
|
|
"ripost": "loi-riposte",
|
|
"aide a mourir": "ppl-aide-a-mourir",
|
|
"philippine": "loi-philippine",
|
|
"reseaux sociaux": "ppl-reseaux-sociaux-mineurs",
|
|
"moins de 15 ans": "ppl-reseaux-sociaux-mineurs",
|
|
"urgence agricole": "loi-urgence-agricole",
|
|
"programmation militaire": "loi-programmation-militaire",
|
|
"lpm": "loi-programmation-militaire",
|
|
"montagne vivante": "ppl-montagne",
|
|
"ppl montagne": "ppl-montagne",
|
|
"patrimoine immobilier": "ppl-patrimoine-immobilier-etat",
|
|
"pacte migration": "pjl-pacte-migration",
|
|
"pacte europeen sur la migration": "pjl-pacte-migration",
|
|
"protection des enfants": "pjl-protection-enfants",
|
|
"cohesion republicaine": "pjl-cohesion-republicaine",
|
|
"logement (jeanbrun)": "pjl-logement",
|
|
"jeanbrun": "pjl-logement",
|
|
"globe": "accord-globe",
|
|
"entrisme": "ppl-entrisme",
|
|
"separatisme": "pjl-separatisme",
|
|
"nunez": "pjl-separatisme",
|
|
"outrage a l'hymne": "ppl-outrage-hymne-drapeau",
|
|
"code noir": "ppl-abrogation-code-noir",
|
|
"mathiasin": "ppl-abrogation-code-noir",
|
|
"adaptation du droit des outre-mer": "ppl-adaptation-droit-outre-mer",
|
|
"emploi durable": "ppl-emploi-ultramarin",
|
|
"ratenon": "ppl-emploi-ultramarin",
|
|
"entites naturelles": "ppl-entites-naturelles",
|
|
"devoir conjugal": "ppl-devoir-conjugal",
|
|
"kazakhstan": "accord-kazakhstan-readmission",
|
|
"colombie": "accord-colombie-extradition",
|
|
"macf": "pjl-ratification-ordonnance-macf",
|
|
}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class MentionTexte:
|
|
"""Ce qu'un chapitre dit d'un texte donné."""
|
|
|
|
texte_id: str
|
|
marqueurs: list[str] = field(default_factory=list)
|
|
phrases: list[str] = field(default_factory=list)
|
|
chapitre: str = ""
|
|
section: str = ""
|
|
# Vrai lorsque le titre de section nomme explicitement ce texte : la
|
|
# section lui est alors consacrée, et sa prose peut servir de résumé.
|
|
section_dediee: bool = False
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LectureChapitres:
|
|
mentions: dict[str, list[MentionTexte]] = field(default_factory=dict)
|
|
|
|
def marqueurs_de(self, texte_id: str) -> list[str]:
|
|
vus: list[str] = []
|
|
for mention in self.mentions.get(texte_id, []):
|
|
for marqueur in mention.marqueurs:
|
|
if marqueur not in vus:
|
|
vus.append(marqueur)
|
|
return vus
|
|
|
|
def resume_de(self, texte_id: str, *, phrases_max: int = 5) -> str | None:
|
|
"""Résumé tiré des sections consacrées au texte, sinon `None`.
|
|
|
|
Aucune phrase n'est reformulée : ce sont celles du rapport, nettoyées
|
|
de leur balisage et de leurs marqueurs de citation.
|
|
"""
|
|
dediees = [m for m in self.mentions.get(texte_id, []) if m.section_dediee]
|
|
candidates = dediees or self.mentions.get(texte_id, [])
|
|
|
|
phrases: list[str] = []
|
|
for mention in candidates:
|
|
for phrase in mention.phrases:
|
|
propre = nettoyer(phrase)
|
|
if len(propre) > 40 and propre not in phrases:
|
|
phrases.append(propre)
|
|
if len(phrases) >= phrases_max:
|
|
break
|
|
if len(phrases) >= phrases_max:
|
|
break
|
|
|
|
if not phrases:
|
|
return None
|
|
return " ".join(phrases[:phrases_max])
|
|
|
|
|
|
def parser() -> LectureChapitres:
|
|
"""Analyse les onze chapitres et rattache leurs mentions aux textes."""
|
|
lecture = LectureChapitres()
|
|
|
|
for numero in range(11):
|
|
fichier = chemins.chapitre(numero)
|
|
if not fichier.exists():
|
|
continue
|
|
nom = f"sec{numero:02d}"
|
|
|
|
for section in decouper_sections(fichier.read_text(encoding="utf-8"), niveau_max=4):
|
|
cibles_du_titre = _textes_designes(section.titre)
|
|
|
|
for paragraphe in _paragraphes(section.corps):
|
|
for phrase in _phrases(paragraphe):
|
|
cibles = _textes_designes(phrase) or cibles_du_titre
|
|
if not cibles:
|
|
continue
|
|
marqueurs = [m.brut for m in extraire_marqueurs(phrase)]
|
|
for cible in cibles:
|
|
_ajouter(
|
|
lecture,
|
|
cible,
|
|
marqueurs=marqueurs,
|
|
phrase=phrase,
|
|
chapitre=nom,
|
|
section=section.titre,
|
|
dediee=cible in cibles_du_titre,
|
|
)
|
|
|
|
log.info(
|
|
"chapitres analysés",
|
|
textes_mentionnes=len(lecture.mentions),
|
|
mentions=sum(len(m) for m in lecture.mentions.values()),
|
|
)
|
|
return lecture
|
|
|
|
|
|
def _ajouter(
|
|
lecture: LectureChapitres,
|
|
texte_id: str,
|
|
*,
|
|
marqueurs: list[str],
|
|
phrase: str,
|
|
chapitre: str,
|
|
section: str,
|
|
dediee: bool,
|
|
) -> None:
|
|
mentions = lecture.mentions.setdefault(texte_id, [])
|
|
courante = next(
|
|
(m for m in mentions if m.chapitre == chapitre and m.section == section), None
|
|
)
|
|
if courante is None:
|
|
courante = MentionTexte(
|
|
texte_id=texte_id, chapitre=chapitre, section=section, section_dediee=dediee
|
|
)
|
|
mentions.append(courante)
|
|
|
|
courante.section_dediee = courante.section_dediee or dediee
|
|
courante.phrases.append(phrase)
|
|
for marqueur in marqueurs:
|
|
if marqueur not in courante.marqueurs:
|
|
courante.marqueurs.append(marqueur)
|
|
|
|
|
|
def _paragraphes(corps: str) -> list[str]:
|
|
"""Paragraphes de prose, tableaux et listes exclus."""
|
|
blocs: list[str] = []
|
|
for bloc in corps.split("\n\n"):
|
|
propre = bloc.strip()
|
|
if not propre or propre.startswith(("|", "#", "- ", "* ", ">")):
|
|
continue
|
|
blocs.append(" ".join(propre.split()))
|
|
return blocs
|
|
|
|
|
|
def _phrases(paragraphe: str) -> list[str]:
|
|
return [p for p in _FIN_DE_PHRASE.split(paragraphe) if p.strip()]
|
|
|
|
|
|
def _textes_designes(fragment: str) -> list[str]:
|
|
"""Identifiants des textes qu'un fragment nomme, par numéro ou nom d'usage."""
|
|
trouves: list[str] = []
|
|
|
|
for numero in _NUMERO_LOI.findall(fragment):
|
|
identifiant = f"loi-{numero}"
|
|
if identifiant not in trouves:
|
|
trouves.append(identifiant)
|
|
|
|
plat = sans_accents(fragment).lower()
|
|
for nom, identifiant in NOMS_D_USAGE.items():
|
|
if nom in plat and identifiant not in trouves:
|
|
trouves.append(identifiant)
|
|
|
|
return trouves
|