Phase 3 : sources, citations et seed complet — 368 sources, 0 texte orphelin

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>
This commit is contained in:
Cyber Mawonaj
2026-07-25 19:43:03 -04:00
co-authored by Claude Opus 5
parent c36b8dc631
commit c28243cf87
12 changed files with 2108 additions and 3 deletions
+222
View File
@@ -0,0 +1,222 @@
"""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"\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