Files
veye-lalwa/pipeline/parseurs/chapitres.py
T
Cyber MawonajandClaude Opus 5 f3e546cfc4 Phase 4 : impacts par public, arbitrages, calendrier — les 7 critères §7 passent
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>
2026-07-25 20:23:51 -04:00

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