"""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