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
|