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:
co-authored by
Claude Opus 5
parent
c36b8dc631
commit
c28243cf87
@@ -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"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
|
||||
Reference in New Issue
Block a user