Impacts : le rapport ne dresse pas de tableau texte × public, mais il consacre un chapitre à chacun (sec04 individus, sec05 associations, sec06 entreprises). C'est ce placement éditorial — un choix explicite de l'auteur, pas une inférence — qui sert de base à la ventilation. Le sens vient de la cotation manuelle de l'annexe quand elle existe, sinon du vocabulaire, et le texte est alors marqué « à vérifier » (17 cas). Arbitrages de la validation de phase 5 portés en points clés : loi-riposte affiche l'AFD à 500 €, et le montant de 1 500 € n'apparaît que présenté comme la version sénatoriale abandonnée. Migration 004 + calendrier : 9 échéances nationales transcrites du corpus avec leur extrait source. Celle des sénatoriales porte explicitement concerne_guadeloupe = 0 — série 1, renouvelée en 2023. Défaut de qualité corrigé : un titre de section nommant plusieurs textes (« Cohésion républicaine, logement, GloBE, entrisme ») faisait hériter chaque phrase de transition aux quatre, polluant leurs résumés. Une section n'est dédiée que si elle nomme un seul texte. 118 tests, 92 % de couverture. Les 7 garde-fous du §7 et les 5 démos du §8.7 sont automatisés dans tests/test_acceptation.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
8.8 KiB
Python
229 lines
8.8 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):
|
|
nommes_au_titre = _textes_designes(section.titre)
|
|
# Un titre qui nomme plusieurs textes — « 3.2.3 Cohésion
|
|
# républicaine, logement, GloBE, entrisme » — n'est dédié à aucun.
|
|
# Sans cette restriction, chaque phrase de transition de la section
|
|
# (« Septembre 2026 sera un mois réglementaire… ») atterrirait dans
|
|
# le résumé des quatre textes à la fois.
|
|
cibles_du_titre = nommes_au_titre if len(nommes_au_titre) == 1 else []
|
|
|
|
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
|