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,113 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Règles de classification du pipeline.
|
||||
#
|
||||
# Ce fichier est le seul endroit où changer les mots-clés : ni les parseurs ni
|
||||
# les collecteurs n'en codent en dur. Toute classification obtenue par ces
|
||||
# règles est marquée « à vérifier » et, pour les textes collectés en ligne,
|
||||
# ramenée à une confiance « low » — conformément au §5.3 du cahier des charges.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Détection de la pertinence Guadeloupe / outre-mer.
|
||||
#
|
||||
# Le score est la somme des poids des expressions trouvées. Les expressions
|
||||
# sont cherchées sans accent ni casse, sur des frontières de mot.
|
||||
guadeloupe:
|
||||
seuils:
|
||||
forte: 6
|
||||
moyenne: 3
|
||||
faible: 1
|
||||
|
||||
expressions:
|
||||
# Le territoire lui-même : décisif.
|
||||
guadeloupe: 6
|
||||
guadeloupeen: 6
|
||||
gwadloup: 6
|
||||
basse-terre: 5
|
||||
pointe-a-pitre: 5
|
||||
baie-mahault: 5
|
||||
les abymes: 5
|
||||
grande-terre: 5
|
||||
marie-galante: 5
|
||||
saint-martin: 4
|
||||
saint-barthelemy: 4
|
||||
|
||||
# Sujets propres à la Guadeloupe.
|
||||
chlordecone: 6
|
||||
octroi de mer: 5
|
||||
lodeom: 4
|
||||
sargasse: 4
|
||||
cgss: 4
|
||||
lkp: 4
|
||||
ugtg: 4
|
||||
caricom: 4
|
||||
code noir: 3
|
||||
|
||||
# Le champ ultramarin en général : indicatif, pas décisif.
|
||||
outre-mer: 3
|
||||
outremer: 3
|
||||
ultramarin: 3
|
||||
drom: 3
|
||||
dom-tom: 2
|
||||
rup: 2
|
||||
region ultraperipherique: 3
|
||||
collectivite unique: 2
|
||||
article 73: 2
|
||||
article 74: 2
|
||||
continuite territoriale: 3
|
||||
|
||||
# Territoires voisins : le texte touche l'outre-mer sans viser la Guadeloupe.
|
||||
martinique: 2
|
||||
guyane: 2
|
||||
la reunion: 2
|
||||
mayotte: 2
|
||||
nouvelle-caledonie: 1
|
||||
polynesie: 1
|
||||
wallis-et-futuna: 1
|
||||
saint-pierre-et-miquelon: 1
|
||||
|
||||
# Contexte caribéen.
|
||||
caraibe: 2
|
||||
antilles: 3
|
||||
haiti: 2
|
||||
zone economique exclusive: 1
|
||||
|
||||
# Résolution des conflits entre sources, du plus fort au plus faible.
|
||||
# Reprend la règle du §5.2 et le fichier `lois-2026_phase5_validation.md`.
|
||||
hierarchie_sources:
|
||||
- legifrance.gouv.fr
|
||||
- journal-officiel.gouv.fr
|
||||
- assemblee-nationale.fr
|
||||
- senat.fr
|
||||
- conseil-constitutionnel.fr
|
||||
- conseil-etat.fr
|
||||
- vie-publique.fr
|
||||
- gouvernement.fr
|
||||
|
||||
# Rapprochement des textes collectés avec ceux déjà en base.
|
||||
rapprochement:
|
||||
# Un numéro officiel identique vaut identité, sans condition.
|
||||
# À défaut, similarité de titre : au-dessus du seuil, on rapproche ;
|
||||
# entre le seuil bas et le seuil, on signale sans rapprocher.
|
||||
seuil_similarite: 0.86
|
||||
seuil_signalement: 0.72
|
||||
|
||||
# Collecteurs : activation et adresses.
|
||||
collecteurs:
|
||||
legifrance:
|
||||
actif: true
|
||||
# Sans identifiants PISTE dans .env, le collecteur se désactive tout seul
|
||||
# et le pipeline bascule sur le repli ci-dessous.
|
||||
repli_si_sans_cle: true
|
||||
assemblee_nationale:
|
||||
actif: true
|
||||
promulgations: https://www.assemblee-nationale.fr/dyn/actualites-accueil/promulgations-de-lois
|
||||
senat:
|
||||
actif: true
|
||||
lois: https://www.senat.fr/lois/index.html
|
||||
conseil_constitutionnel:
|
||||
actif: true
|
||||
decisions: https://www.conseil-constitutionnel.fr/decisions
|
||||
affaires_en_instance: https://www.conseil-constitutionnel.fr/decisions/affaires-en-instances
|
||||
vie_publique:
|
||||
actif: true
|
||||
actualites: https://www.vie-publique.fr/loi
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Détection de la pertinence guadeloupéenne et ultramarine d'un texte.
|
||||
|
||||
Le tableau A de l'annexe cote lui-même les 27 lois promulguées ; le tableau B
|
||||
ne cote pas les textes en cours. Pour ces derniers, la cotation est déduite du
|
||||
vocabulaire employé par le rapport, avec des poids déclarés dans
|
||||
`pipeline/config.yaml`.
|
||||
|
||||
Toute cotation obtenue ainsi est signalée : le texte porte `a_verifier = 1` et
|
||||
un motif explicite. L'interface l'affiche comme telle. Une cotation déduite
|
||||
n'est pas une cotation établie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
from pipeline import chemins
|
||||
from pipeline.modeles import Pertinence
|
||||
from pipeline.parseurs.dates_fr import sans_accents
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Cotation:
|
||||
"""Résultat d'une détection, avec ce qui l'a motivée."""
|
||||
|
||||
pertinence: Pertinence | None
|
||||
score: int
|
||||
expressions: list[str]
|
||||
|
||||
@property
|
||||
def note(self) -> str | None:
|
||||
if not self.expressions:
|
||||
return None
|
||||
return "Détecté d'après : " + ", ".join(self.expressions[:6])
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _configuration() -> dict:
|
||||
return yaml.safe_load(chemins.CONFIG.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _motifs() -> list[tuple[re.Pattern[str], str, int]]:
|
||||
"""Compile un motif par expression, ancré sur des frontières de mot."""
|
||||
expressions = _configuration()["guadeloupe"]["expressions"]
|
||||
compiles: list[tuple[re.Pattern[str], str, int]] = []
|
||||
|
||||
for expression, poids in expressions.items():
|
||||
plat = sans_accents(str(expression)).lower()
|
||||
compiles.append((re.compile(rf"(?<!\w){re.escape(plat)}\w{{0,3}}(?!\w)"), plat, int(poids)))
|
||||
|
||||
# Les expressions les plus lourdes d'abord : le compte-rendu cite en
|
||||
# priorité ce qui a réellement décidé de la cotation.
|
||||
compiles.sort(key=lambda triplet: -triplet[2])
|
||||
return compiles
|
||||
|
||||
|
||||
def coter(*fragments: str | None) -> Cotation:
|
||||
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte."""
|
||||
plat = sans_accents(" ".join(f for f in fragments if f)).lower()
|
||||
if not plat.strip():
|
||||
return Cotation(pertinence=None, score=0, expressions=[])
|
||||
|
||||
score = 0
|
||||
trouvees: list[str] = []
|
||||
|
||||
for motif, expression, poids in _motifs():
|
||||
if motif.search(plat):
|
||||
score += poids
|
||||
trouvees.append(expression)
|
||||
|
||||
seuils = _configuration()["guadeloupe"]["seuils"]
|
||||
if score >= seuils["forte"]:
|
||||
pertinence = Pertinence.FORTE
|
||||
elif score >= seuils["moyenne"]:
|
||||
pertinence = Pertinence.MOYENNE
|
||||
elif score >= seuils["faible"]:
|
||||
pertinence = Pertinence.FAIBLE
|
||||
else:
|
||||
pertinence = None
|
||||
|
||||
return Cotation(pertinence=pertinence, score=score, expressions=trouvees)
|
||||
|
||||
|
||||
MOTIF_VERIFICATION = (
|
||||
"Pertinence Guadeloupe déduite automatiquement : l'annexe du rapport ne "
|
||||
"cote que les lois promulguées, pas les textes encore en discussion."
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Lecture des analyses transversales (`research/lois-2026_insight.md`).
|
||||
|
||||
Sept analyses, chacune au format ::
|
||||
|
||||
## Insight 3 — Le 1er septembre 2026 : « big bang » silencieux …
|
||||
**Dérivé de :** dim08, dim09, dim02, dim12, dim03.
|
||||
<corps>
|
||||
**Implications :** …
|
||||
**Confiance : high.**
|
||||
|
||||
Elles alimentent l'encart « point de veille » du tableau de bord. Elles ne
|
||||
portent aucune source primaire propre — la table de correspondance de la
|
||||
bibliographie le dit — et ne sont donc jamais présentées comme des faits
|
||||
sourcés, mais comme des lectures d'ensemble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Insight
|
||||
from pipeline.parseurs.markdown import decouper_sections, nettoyer
|
||||
|
||||
log = logger("parseur.analyses")
|
||||
|
||||
_TITRE = re.compile(r"^Insight\s+(\d+)\s*[—–-]\s*(.+)$", re.IGNORECASE)
|
||||
_DERIVE = re.compile(r"\*\*Dérivé de\s*:\*\*\s*(.+)", re.IGNORECASE)
|
||||
_IMPLICATIONS = re.compile(r"\*\*Implications\s*:\*\*\s*(.+)", re.IGNORECASE)
|
||||
_CONFIANCE = re.compile(r"\*\*Confiance\s*:\s*([a-zé-]+)", re.IGNORECASE)
|
||||
_DIMENSION = re.compile(r"\b(dim\d{2}|phase\s?5)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def parser(markdown: str) -> list[Insight]:
|
||||
"""Extrait les analyses transversales du fichier d'insights."""
|
||||
analyses: list[Insight] = []
|
||||
|
||||
for section in decouper_sections(markdown, niveau_max=2):
|
||||
entete = _TITRE.match(section.titre.strip())
|
||||
if not entete:
|
||||
continue
|
||||
|
||||
corps_brut = section.corps
|
||||
implications = None
|
||||
if trouve := _IMPLICATIONS.search(corps_brut):
|
||||
implications = nettoyer(trouve.group(1))
|
||||
|
||||
confiance = "medium"
|
||||
if trouve := _CONFIANCE.search(corps_brut):
|
||||
brut = trouve.group(1).lower()
|
||||
confiance = {"high": "high", "élevée": "high", "medium": "medium", "low": "low"}.get(
|
||||
brut, "medium"
|
||||
)
|
||||
|
||||
derive_de: list[str] = []
|
||||
if trouve := _DERIVE.search(corps_brut):
|
||||
derive_de = [
|
||||
d.lower().replace(" ", "") for d in _DIMENSION.findall(trouve.group(1))
|
||||
]
|
||||
|
||||
corps = _corps_utile(corps_brut)
|
||||
|
||||
analyses.append(
|
||||
Insight(
|
||||
numero=int(entete.group(1)),
|
||||
titre=nettoyer(entete.group(2)),
|
||||
corps=corps,
|
||||
implications=implications,
|
||||
confiance=confiance,
|
||||
derive_de=derive_de,
|
||||
fichier_origine="research/lois-2026_insight.md",
|
||||
)
|
||||
)
|
||||
|
||||
log.info("analyses transversales lues", analyses=len(analyses))
|
||||
return analyses
|
||||
|
||||
|
||||
def _corps_utile(corps: str) -> str:
|
||||
"""Corps de l'analyse, sans les lignes de métadonnées."""
|
||||
lignes: list[str] = []
|
||||
for ligne in corps.splitlines():
|
||||
depouillee = ligne.strip()
|
||||
if not depouillee:
|
||||
continue
|
||||
if _DERIVE.match(depouillee) or _IMPLICATIONS.match(depouillee):
|
||||
continue
|
||||
if _CONFIANCE.match(depouillee):
|
||||
continue
|
||||
lignes.append(nettoyer(depouillee))
|
||||
return " ".join(lignes).strip()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Lecture de la bibliographie consolidée (`lois-france-2026-guadeloupe_ref.md`).
|
||||
|
||||
481 références au format ::
|
||||
|
||||
[1] Aida. Loi ndeg 2026 554 290626 visant a relancer investissements secteur
|
||||
lhydroelectricite [EB/OL]. 29-30 juin 2026 (publication). https://…
|
||||
|
||||
C'est la seule liste du corpus intégralement pourvue d'URLs : elle sert de
|
||||
filet pour garantir qu'aucun texte n'entre en base sans lien vérifiable.
|
||||
|
||||
Le titre y est translittéré sans accent ni ponctuation (« ndeg » pour « n° »),
|
||||
ce qui interdit le rapprochement par libellé. En revanche les numéros de loi
|
||||
y survivent, sous forme éclatée : « 2026 554 » pour « 2026-554 ». C'est ce
|
||||
motif qui est exploité pour rattacher une référence à un texte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs.dates_fr import lire_date, sans_accents
|
||||
from pipeline.parseurs.markdown import nettoyer
|
||||
|
||||
log = logger("parseur.bibliographie")
|
||||
|
||||
ANNEE_CORPUS = 2026
|
||||
|
||||
_LIGNE_REFERENCE = re.compile(r"^\[(\d+)\]\s+(.*)$")
|
||||
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||||
|
||||
# « ndeg 2026 554 », « 2026-554 », « n° 2026-491 » : le numéro de loi survit à
|
||||
# la translittération sous une forme ou une autre.
|
||||
_NUMERO_LOI = re.compile(r"\b(?:ndeg\s*|n°\s*|no\s*)?(\d{4})[\s-](\d{3,4})\b")
|
||||
|
||||
# Numéro d'affaire du Conseil constitutionnel, également translittéré.
|
||||
_AFFAIRE_CC = re.compile(r"\b(\d{4})[\s-](\d{3,4})\s*DC\b", re.IGNORECASE)
|
||||
|
||||
# Numéro de document parlementaire, sous ses trois graphies : la cote brute
|
||||
# « n° 3025 », la cote de dossier de l'Assemblée « l17b3025 » et celle du Sénat
|
||||
# « ppl25-691 » / « pjl25-890 ». C'est souvent le seul point commun entre la
|
||||
# note laconique d'un rapport de dimension (« Texte n° 3025 ») et l'entrée
|
||||
# bibliographique qui porte l'URL (« L17b3025 proposition loi »).
|
||||
_DOCUMENT_AN = re.compile(r"\bl\d{2}b(\d{3,4})\b", re.IGNORECASE)
|
||||
_DOCUMENT_SENAT = re.compile(r"\b(?:ppl|pjl)\d{2}-(\d{3,4})\b", re.IGNORECASE)
|
||||
_DOCUMENT_NUMERO = re.compile(r"\bn(?:°|deg|o)\s*(\d{3,4})\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Reference:
|
||||
"""Une entrée de la bibliographie consolidée."""
|
||||
|
||||
numero: int
|
||||
editeur: str | None
|
||||
titre: str
|
||||
date_publication: date | None
|
||||
url: str | None
|
||||
numeros_cites: list[str] = field(default_factory=list)
|
||||
affaires_citees: list[str] = field(default_factory=list)
|
||||
documents_cites: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def marqueur(self) -> str:
|
||||
return f"ref-{self.numero}"
|
||||
|
||||
def en_source(self) -> Source | None:
|
||||
if not self.url:
|
||||
return None
|
||||
return Source(
|
||||
url=self.url,
|
||||
titre=self.titre[:280] or None,
|
||||
editeur=self.editeur,
|
||||
date_publication=self.date_publication,
|
||||
confiance=Confiance.MEDIUM,
|
||||
marqueur=self.marqueur,
|
||||
fichier_origine="lois-france-2026-guadeloupe_ref.md",
|
||||
)
|
||||
|
||||
|
||||
def parser(markdown: str) -> list[Reference]:
|
||||
"""Extrait toutes les références de la bibliographie."""
|
||||
references: list[Reference] = []
|
||||
|
||||
for ligne in markdown.splitlines():
|
||||
correspondance = _LIGNE_REFERENCE.match(ligne.strip())
|
||||
if not correspondance:
|
||||
continue
|
||||
references.append(_lire_reference(int(correspondance.group(1)), correspondance.group(2)))
|
||||
|
||||
avec_url = sum(1 for r in references if r.url)
|
||||
log.debug("bibliographie analysée", references=len(references), avec_url=avec_url)
|
||||
return references
|
||||
|
||||
|
||||
def _lire_reference(numero: int, corps: str) -> Reference:
|
||||
url = None
|
||||
if trouvee := _URL.search(corps):
|
||||
url = trouvee.group(0).rstrip(".,;)")
|
||||
|
||||
sans_lien = _URL.sub("", corps).strip()
|
||||
|
||||
editeur: str | None = None
|
||||
titre = sans_lien
|
||||
if ". " in sans_lien:
|
||||
candidat, reste = sans_lien.split(". ", 1)
|
||||
# Un éditeur est court : au-delà, c'est que la phrase a été coupée sur
|
||||
# une abréviation (« n° 2026-491. »).
|
||||
if len(candidat) <= 60:
|
||||
editeur, titre = candidat.strip(), reste
|
||||
|
||||
titre = titre.split("[EB/OL]", 1)[0].strip(" .;")
|
||||
|
||||
return Reference(
|
||||
numero=numero,
|
||||
editeur=nettoyer(editeur) if editeur else None,
|
||||
titre=nettoyer(titre)[:400],
|
||||
date_publication=lire_date(sans_lien, annee_defaut=ANNEE_CORPUS),
|
||||
url=url,
|
||||
numeros_cites=_numeros_de_loi(sans_lien),
|
||||
affaires_citees=_affaires_cc(sans_lien),
|
||||
documents_cites=numeros_de_document(f"{sans_lien} {url or ''}"),
|
||||
)
|
||||
|
||||
|
||||
def _numeros_de_loi(texte: str) -> list[str]:
|
||||
"""Numéros de loi cités, normalisés au format « 2026-554 »."""
|
||||
plat = sans_accents(texte).lower()
|
||||
trouves: list[str] = []
|
||||
|
||||
for annee, rang in _NUMERO_LOI.findall(plat):
|
||||
# Seules les années plausibles pour la fenêtre de veille comptent.
|
||||
if annee not in {"2024", "2025", "2026"}:
|
||||
continue
|
||||
# « programmation militaire 2024-2030 » n'est pas la loi n° 2024-2030 :
|
||||
# deux millésimes croissants forment une plage, pas un numéro. Le test
|
||||
# inverse laisse passer les vrais numéros à quatre chiffres, dont le
|
||||
# rang est toujours inférieur au millésime (loi n° 2021-1947).
|
||||
if 1900 <= int(rang) <= 2100 and int(rang) > int(annee):
|
||||
continue
|
||||
numero = f"{annee}-{int(rang)}"
|
||||
if numero not in trouves:
|
||||
trouves.append(numero)
|
||||
|
||||
return trouves
|
||||
|
||||
|
||||
def _affaires_cc(texte: str) -> list[str]:
|
||||
plat = sans_accents(texte)
|
||||
trouvees: list[str] = []
|
||||
for annee, rang in _AFFAIRE_CC.findall(plat):
|
||||
affaire = f"{annee}-{int(rang)} DC"
|
||||
if affaire not in trouvees:
|
||||
trouvees.append(affaire)
|
||||
return trouvees
|
||||
|
||||
|
||||
_LIGNE_CORRESPONDANCE = re.compile(r"^\|\s*(dim\d{2}|phase5|insight)\s*\(?[^|]*\|\s*([^|]*)\|")
|
||||
_PLAGE = re.compile(r"(\d+)\s*[–—-]\s*(\d+)")
|
||||
|
||||
|
||||
def lire_table_de_correspondance(markdown: str) -> dict[str, set[int]]:
|
||||
"""Lit la table « préfixe de citation → références [N] correspondantes ».
|
||||
|
||||
La bibliographie se termine par un tableau qui indique, pour chaque rapport
|
||||
de dimension, quelles références globales lui appartiennent. C'est ce
|
||||
tableau qui rend possible le rapprochement d'une note sans URL avec son
|
||||
entrée bibliographique : sans lui, il faudrait chercher parmi 481 candidates
|
||||
au lieu d'une cinquantaine.
|
||||
"""
|
||||
correspondances: dict[str, set[int]] = {}
|
||||
|
||||
for ligne in markdown.splitlines():
|
||||
entree = _LIGNE_CORRESPONDANCE.match(ligne.strip())
|
||||
if not entree:
|
||||
continue
|
||||
|
||||
prefixe, cellule = entree.group(1), entree.group(2)
|
||||
numeros: set[int] = set()
|
||||
|
||||
for morceau in cellule.split(","):
|
||||
morceau = morceau.strip()
|
||||
if plage := _PLAGE.search(morceau):
|
||||
debut, fin = int(plage.group(1)), int(plage.group(2))
|
||||
if debut <= fin:
|
||||
numeros.update(range(debut, fin + 1))
|
||||
elif morceau.isdigit():
|
||||
numeros.add(int(morceau))
|
||||
|
||||
if numeros:
|
||||
correspondances[prefixe] = numeros
|
||||
|
||||
return correspondances
|
||||
|
||||
|
||||
def numeros_de_document(texte: str) -> list[str]:
|
||||
"""Cotes de documents parlementaires citées, normalisées en chiffres seuls."""
|
||||
plat = sans_accents(texte)
|
||||
trouves: list[str] = []
|
||||
for motif in (_DOCUMENT_AN, _DOCUMENT_SENAT, _DOCUMENT_NUMERO):
|
||||
for numero in motif.findall(plat):
|
||||
depouille = str(int(numero))
|
||||
if depouille not in trouves:
|
||||
trouves.append(depouille)
|
||||
return trouves
|
||||
|
||||
|
||||
def indexer_par_document(references: list[Reference]) -> dict[str, list[Reference]]:
|
||||
"""Index cote de document → références qui la citent."""
|
||||
index: dict[str, list[Reference]] = {}
|
||||
for reference in references:
|
||||
for document in reference.documents_cites:
|
||||
index.setdefault(document, []).append(reference)
|
||||
return index
|
||||
|
||||
|
||||
def indexer_par_numero(references: list[Reference]) -> dict[str, list[Reference]]:
|
||||
"""Index numéro de loi → références qui le citent."""
|
||||
index: dict[str, list[Reference]] = {}
|
||||
for reference in references:
|
||||
for numero in reference.numeros_cites:
|
||||
index.setdefault(numero, []).append(reference)
|
||||
return index
|
||||
|
||||
|
||||
def indexer_par_affaire(references: list[Reference]) -> dict[str, list[Reference]]:
|
||||
"""Index numéro d'affaire DC → références qui la citent."""
|
||||
index: dict[str, list[Reference]] = {}
|
||||
for reference in references:
|
||||
for affaire in reference.affaires_citees:
|
||||
index.setdefault(affaire, []).append(reference)
|
||||
return index
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Lecture des blocs sourcés des rapports de dimension (`research/dim01` → `dim12`).
|
||||
|
||||
Le cahier des charges postule un format unique
|
||||
`Claim / Source / URL / Date / Excerpt / Confidence`. Le corpus réel en compte
|
||||
**quatre dialectes**, produits par des agents de recherche différents :
|
||||
|
||||
- **A — claim plat** (dim01 à dim04, dim07) ::
|
||||
|
||||
Claim: … [^24^]
|
||||
Source: Vie-publique.fr
|
||||
URL: https://…
|
||||
Confidence: high
|
||||
|
||||
- **B — claim numéroté en gras** (dim05) :: ``**Claim 3 :** … [^3^]``
|
||||
|
||||
- **C — claim en liste** (dim10, portions de dim06 et dim08) ::
|
||||
|
||||
**Claim 1.2** — …
|
||||
- **Source** : Conseil d'État
|
||||
- **URL** : https://…
|
||||
|
||||
- **D — prose et notes** (dim06, dim08, dim09, dim11, dim12) : aucun bloc, les
|
||||
sources vivent dans une liste de notes en fin de fichier (voir
|
||||
`notes_bas_page`).
|
||||
|
||||
Un parseur qui n'aurait vu que le dialecte A perdrait dim05 à dim12, c'est-à-dire
|
||||
les dimensions associations, entreprises, migration et calendrier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs.dates_fr import lire_date
|
||||
from pipeline.parseurs.markdown import MOTIF_MARQUEUR, nettoyer
|
||||
|
||||
log = logger("parseur.claims")
|
||||
|
||||
ANNEE_CORPUS = 2026
|
||||
|
||||
# Une ligne de claim, tous dialectes : « Claim: », « **Claim 3 :** »,
|
||||
# « **Claim 1.2** — », « - **Claim**: », et la forme de la validation de
|
||||
# phase 5 qui glisse une parenthèse entre le numéro et le deux-points :
|
||||
# « **Claim 1.1 (décisif, source primaire) : … » — sans quoi ce fichier ne
|
||||
# rend que 2 blocs sur 24.
|
||||
_LIGNE_CLAIM = re.compile(
|
||||
r"^\s*(?:[-*]\s*)?\*{0,2}\s*Claim\s*([\d.]*)\s*(?:\([^)]*\))?\s*\*{0,2}\s*[:—–-]\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Un champ du bloc, avec ou sans puce, avec ou sans gras, avec le deux-points
|
||||
# à l'intérieur ou à l'extérieur des astérisques.
|
||||
_LIGNE_CHAMP = re.compile(
|
||||
r"^\s*(?:[-*]\s*)?\*{0,2}\s*"
|
||||
r"(Source|URL|Date|Excerpt|Extrait|Contexte|Context|Confidence|Confiance)"
|
||||
r"\s*\*{0,2}\s*:\s*\*{0,2}\s*(.*?)\s*\*{0,2}\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_CHAMPS = {
|
||||
"source": "source",
|
||||
"url": "url",
|
||||
"date": "date",
|
||||
"excerpt": "excerpt",
|
||||
"extrait": "excerpt",
|
||||
"context": "contexte",
|
||||
"contexte": "contexte",
|
||||
"confidence": "confiance",
|
||||
"confiance": "confiance",
|
||||
}
|
||||
|
||||
# Formes relevées dans le corpus, du plus fort au plus faible.
|
||||
_CONFIANCES: tuple[tuple[str, Confiance], ...] = (
|
||||
("tres elevee", Confiance.HIGH),
|
||||
("très élevée", Confiance.HIGH),
|
||||
("medium-high", Confiance.MEDIUM),
|
||||
("moyenne-haute", Confiance.MEDIUM),
|
||||
("high", Confiance.HIGH),
|
||||
("haute", Confiance.HIGH),
|
||||
("elevee", Confiance.HIGH),
|
||||
("élevée", Confiance.HIGH),
|
||||
("medium", Confiance.MEDIUM),
|
||||
("moyenne", Confiance.MEDIUM),
|
||||
("low", Confiance.LOW),
|
||||
("faible", Confiance.LOW),
|
||||
)
|
||||
|
||||
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BlocClaim:
|
||||
"""Une affirmation sourcée extraite d'un rapport de dimension."""
|
||||
|
||||
dimension: str # « dim07 »
|
||||
affirmation: str
|
||||
marqueurs: list[str] = field(default_factory=list) # numéros locaux : ["24"]
|
||||
editeur: str | None = None
|
||||
urls: list[str] = field(default_factory=list)
|
||||
date_publication: date | None = None
|
||||
extrait: str | None = None
|
||||
contexte: str | None = None
|
||||
confiance: Confiance = Confiance.MEDIUM
|
||||
ligne: int = 0
|
||||
|
||||
def en_sources(self) -> list[Source]:
|
||||
"""Convertit le bloc en sources exploitables — une par URL citée."""
|
||||
sources: list[Source] = []
|
||||
for url in self.urls:
|
||||
try:
|
||||
sources.append(
|
||||
Source(
|
||||
url=url,
|
||||
titre=self.affirmation[:280] or None,
|
||||
editeur=self.editeur,
|
||||
date_publication=self.date_publication,
|
||||
extrait_verbatim=self.extrait,
|
||||
contexte=self.contexte,
|
||||
confiance=self.confiance,
|
||||
marqueur=f"{self.dimension}-{self.marqueurs[0]}"
|
||||
if self.marqueurs
|
||||
else self.dimension,
|
||||
fichier_origine=f"research/lois-2026_{self.dimension}.md",
|
||||
)
|
||||
)
|
||||
except ValueError as erreur: # URL inexploitable : on ignore, sans bruit
|
||||
log.debug("URL rejetée", url=url, motif=str(erreur))
|
||||
return sources
|
||||
|
||||
|
||||
def parser(markdown: str, dimension: str) -> list[BlocClaim]:
|
||||
"""Extrait tous les blocs sourcés d'un rapport de dimension."""
|
||||
blocs: list[BlocClaim] = []
|
||||
courant: BlocClaim | None = None
|
||||
|
||||
for numero, ligne in enumerate(markdown.splitlines(), start=1):
|
||||
if debut := _LIGNE_CLAIM.match(ligne):
|
||||
courant = _ouvrir_bloc(debut.group(2), dimension, numero)
|
||||
blocs.append(courant)
|
||||
continue
|
||||
|
||||
if courant is None:
|
||||
continue
|
||||
|
||||
if champ := _LIGNE_CHAMP.match(ligne):
|
||||
_remplir(courant, _CHAMPS[champ.group(1).lower()], champ.group(2))
|
||||
continue
|
||||
|
||||
# Un titre de section referme le bloc en cours ; le reste (prose,
|
||||
# lignes vides) est ignoré sans le refermer, car certains blocs sont
|
||||
# entrecoupés de commentaires du rédacteur.
|
||||
if ligne.lstrip().startswith("#"):
|
||||
courant = None
|
||||
|
||||
complets = [b for b in blocs if b.urls]
|
||||
log.debug(
|
||||
"blocs analysés",
|
||||
dimension=dimension,
|
||||
blocs=len(blocs),
|
||||
avec_url=len(complets),
|
||||
)
|
||||
return blocs
|
||||
|
||||
|
||||
def _ouvrir_bloc(affirmation: str, dimension: str, ligne: int) -> BlocClaim:
|
||||
marqueurs = MOTIF_MARQUEUR.findall(affirmation)
|
||||
return BlocClaim(
|
||||
dimension=dimension,
|
||||
affirmation=nettoyer(affirmation),
|
||||
marqueurs=[m for m in marqueurs if m.isdigit() or "." in m],
|
||||
ligne=ligne,
|
||||
)
|
||||
|
||||
|
||||
def _remplir(bloc: BlocClaim, champ: str, valeur: str) -> None:
|
||||
valeur = valeur.strip()
|
||||
if not valeur:
|
||||
return
|
||||
|
||||
match champ:
|
||||
case "source":
|
||||
bloc.editeur = nettoyer(valeur)[:200] or None
|
||||
case "url":
|
||||
# Une ligne peut citer plusieurs URLs séparées par « ; ».
|
||||
bloc.urls.extend(u for u in _URL.findall(valeur) if u not in bloc.urls)
|
||||
case "date":
|
||||
if lue := lire_date(valeur, annee_defaut=ANNEE_CORPUS):
|
||||
bloc.date_publication = lue
|
||||
case "excerpt":
|
||||
bloc.extrait = _nettoyer_extrait(valeur)
|
||||
case "contexte":
|
||||
bloc.contexte = nettoyer(valeur)[:1000] or None
|
||||
case "confiance":
|
||||
bloc.confiance = lire_confiance(valeur)
|
||||
|
||||
|
||||
def _nettoyer_extrait(valeur: str) -> str | None:
|
||||
"""Conserve l'extrait verbatim, sans les guillemets ni l'italique du corpus."""
|
||||
extrait = valeur.strip()
|
||||
for ouvrant, fermant in (("«", "»"), ('"', '"'), ("“", "”")):
|
||||
extrait = extrait.strip().removeprefix(ouvrant).removesuffix(fermant).strip()
|
||||
extrait = extrait.replace("*", "").strip()
|
||||
extrait = MOTIF_MARQUEUR.sub("", extrait)
|
||||
return " ".join(extrait.split())[:2000] or None
|
||||
|
||||
|
||||
def lire_confiance(valeur: str) -> Confiance:
|
||||
"""Traduit les huit formes de confiance rencontrées dans le corpus."""
|
||||
plat = valeur.lower().replace("*", "").strip()
|
||||
for forme, confiance in _CONFIANCES:
|
||||
if plat.startswith(forme):
|
||||
return confiance
|
||||
return Confiance.MEDIUM
|
||||
@@ -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
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Résolution des marqueurs de citation du corpus.
|
||||
|
||||
Le rapport consolidé relie chacun de ses faits à sa source par un marqueur de
|
||||
la forme ``[^dim07-24^]``. La chaîne de résolution est ::
|
||||
|
||||
sec07.md : « … chlordécone [^dim07-24^] »
|
||||
→ rapport de dimension dim07, référence locale n° 24
|
||||
→ bloc sourcé ou note de bas de page portant ce numéro
|
||||
→ Source vérifiable (URL, éditeur, date, extrait verbatim)
|
||||
|
||||
Encore faut-il savoir ce que « référence locale n° 24 » désigne : le corpus
|
||||
emploie **six conventions de numérotation** selon la dimension, appliquées ici
|
||||
par ordre de priorité décroissante.
|
||||
|
||||
============= ========================================= =========================
|
||||
Convention Exemple Dimensions concernées
|
||||
============= ========================================= =========================
|
||||
marqueur ``Claim: … [^24^]`` dim01, dim02, dim04,
|
||||
de fin dim05, dim07, dim08
|
||||
en-tête ``**Claim 1.2** — …`` dim10, phase5
|
||||
de claim
|
||||
section ``### 1.4. LPM actualisée …`` dim10, dim11, dim12
|
||||
rang 1ᵉʳ claim du fichier = n° 1 dim03
|
||||
note ``[^1614^] assemblee-nationale.fr — …`` dim06, dim08, dim09
|
||||
section.rang section 2, 1ᵉʳ bloc → « 2.1 » dim11
|
||||
============= ========================================= =========================
|
||||
|
||||
Les indexer toutes fait passer la résolution de 55 % à 100 % des 332 marqueurs
|
||||
du rapport, dont 93 % avec un lien vérifiable. Les 7 % restants désignent une
|
||||
référence réelle mais dépourvue d'URL dans le corpus — presque toutes dans
|
||||
dim09, qui ne compte que cinq lignes contenant une adresse pour soixante-cinq
|
||||
notes ; `_rapprocher_notes_sans_lien` en récupère une partie via la
|
||||
bibliographie consolidée.
|
||||
|
||||
`insight` est le seul préfixe volontairement non résolu : la table de
|
||||
correspondance de la bibliographie indique qu'il ne porte « aucune source
|
||||
primaire propre ».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pipeline import chemins
|
||||
from pipeline.journal import logger
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs import bibliographie, blocs_claim, notes_bas_page
|
||||
from pipeline.parseurs.blocs_claim import BlocClaim
|
||||
from pipeline.parseurs.dates_fr import sans_accents
|
||||
from pipeline.parseurs.markdown import Marqueur, extraire_marqueurs
|
||||
from pipeline.parseurs.notes_bas_page import Note
|
||||
|
||||
log = logger("parseur.citations")
|
||||
|
||||
DIMENSIONS_SANS_SOURCE = frozenset({"insight"})
|
||||
|
||||
# « ### 1.4. LPM actualisée … », « ## 3.1 Parcours … », « ## POINT 5 — … »
|
||||
_ENTETE_NUMEROTEE = re.compile(r"^#{2,4}\s+(?:POINT\s+)?([\d]+(?:\.[\d]+)*)\.?\s", re.IGNORECASE)
|
||||
_ENTETE_CLAIM = re.compile(r"^\s*(?:[-*]\s*)?\*{0,2}\s*Claim\s+([\d]+(?:\.[\d]+)*)", re.IGNORECASE)
|
||||
|
||||
# Mots trop courants pour distinguer deux références l'une de l'autre.
|
||||
_MOTS_VIDES = frozenset(
|
||||
"""a au aux avec ce ces dans de des du elle en et eux il ils je la le les leur
|
||||
lui ma mais me meme mes moi mon ne nos notre nous on ou par pas pour qu que qui
|
||||
sa se ses son sur ta te tes toi ton tu un une vos votre vous sont est etre plus
|
||||
loi projet proposition texte article articles n no ndeg juillet juin mai avril
|
||||
aout septembre octobre 2026 2025 fr com www http https org gouv""".split()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Resolution:
|
||||
"""Ce qu'un marqueur désigne, avec ou sans lien exploitable."""
|
||||
|
||||
marqueur: str
|
||||
source: Source | None = None
|
||||
libelle: str | None = None # texte de la référence, même sans URL
|
||||
origine: str = "" # « bloc », « note », « section », « rang »
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Repertoire:
|
||||
"""Index de résolution construit une fois pour tout le seed."""
|
||||
|
||||
entrees: dict[str, dict[str, Resolution]] = field(default_factory=dict)
|
||||
references: list[bibliographie.Reference] = field(default_factory=list)
|
||||
par_numero_loi: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
par_affaire_cc: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
par_document: dict[str, list[bibliographie.Reference]] = field(default_factory=dict)
|
||||
blocs_par_dimension: dict[str, list[BlocClaim]] = field(default_factory=dict)
|
||||
correspondances: dict[str, set[int]] = field(default_factory=dict)
|
||||
rapprochements: int = 0
|
||||
|
||||
# Diagnostic d'import
|
||||
resolus_avec_lien: list[str] = field(default_factory=list)
|
||||
resolus_sans_lien: list[str] = field(default_factory=list)
|
||||
non_resolus: list[str] = field(default_factory=list)
|
||||
|
||||
# ── Résolution ───────────────────────────────────────────────────────────
|
||||
def _entree(self, marqueur: Marqueur) -> Resolution | None:
|
||||
if not marqueur.dimension or marqueur.dimension in DIMENSIONS_SANS_SOURCE:
|
||||
return None
|
||||
return self.entrees.get(marqueur.dimension, {}).get(marqueur.numero)
|
||||
|
||||
def resoudre(self, marqueur: Marqueur | str) -> Source | None:
|
||||
"""Rend la source d'un marqueur, ou `None` s'il n'en désigne aucune."""
|
||||
marqueur = _normaliser(marqueur)
|
||||
if marqueur is None:
|
||||
return None
|
||||
|
||||
entree = self._entree(marqueur)
|
||||
if entree is None:
|
||||
if marqueur.dimension not in DIMENSIONS_SANS_SOURCE:
|
||||
self.non_resolus.append(marqueur.brut)
|
||||
return None
|
||||
|
||||
if entree.source is not None:
|
||||
self.resolus_avec_lien.append(marqueur.brut)
|
||||
return entree.source
|
||||
|
||||
self.resolus_sans_lien.append(marqueur.brut)
|
||||
return None
|
||||
|
||||
def libelle(self, marqueur: Marqueur | str) -> str | None:
|
||||
"""Texte de la référence, y compris lorsqu'elle n'a pas d'URL."""
|
||||
marqueur = _normaliser(marqueur)
|
||||
entree = self._entree(marqueur) if marqueur else None
|
||||
return entree.libelle if entree else None
|
||||
|
||||
def resoudre_plusieurs(self, marqueurs: list[Marqueur] | list[str]) -> list[Source]:
|
||||
"""Résout une liste de marqueurs, en écartant les doublons d'URL."""
|
||||
sources: list[Source] = []
|
||||
vues: set[str] = set()
|
||||
for marqueur in marqueurs:
|
||||
source = self.resoudre(marqueur)
|
||||
if source and source.url not in vues:
|
||||
vues.add(source.url)
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
def references_du_texte(
|
||||
self,
|
||||
*,
|
||||
numero_loi: str | None = None,
|
||||
affaires: list[str] | None = None,
|
||||
documents: list[str] | None = None,
|
||||
) -> list[Source]:
|
||||
"""Sources bibliographiques pour un numéro de loi, une affaire ou une cote.
|
||||
|
||||
La recherche par cote de document parlementaire est le seul recours
|
||||
pour les textes qui n'ont pas de numéro de loi : une proposition
|
||||
déposée et non examinée n'est identifiée que par sa cote, « AN n° 3025 ».
|
||||
"""
|
||||
sources: list[Source] = []
|
||||
vues: set[str] = set()
|
||||
|
||||
candidates: list[bibliographie.Reference] = []
|
||||
if numero_loi:
|
||||
candidates.extend(self.par_numero_loi.get(numero_loi, []))
|
||||
for affaire in affaires or []:
|
||||
candidates.extend(self.par_affaire_cc.get(affaire, []))
|
||||
for document in documents or []:
|
||||
candidates.extend(self.par_document.get(document, []))
|
||||
|
||||
for reference in candidates:
|
||||
source = reference.en_source()
|
||||
if source and source.url not in vues:
|
||||
vues.add(source.url)
|
||||
sources.append(source)
|
||||
|
||||
return sources
|
||||
|
||||
@property
|
||||
def taux_de_resolution(self) -> float:
|
||||
"""Part des marqueurs qui aboutissent à une source ou à une référence."""
|
||||
total = len(self.resolus_avec_lien) + len(self.resolus_sans_lien) + len(self.non_resolus)
|
||||
if not total:
|
||||
return 0.0
|
||||
return (len(self.resolus_avec_lien) + len(self.resolus_sans_lien)) / total
|
||||
|
||||
def remettre_a_zero_diagnostic(self) -> None:
|
||||
self.resolus_avec_lien.clear()
|
||||
self.resolus_sans_lien.clear()
|
||||
self.non_resolus.clear()
|
||||
|
||||
|
||||
def _normaliser(marqueur: Marqueur | str) -> Marqueur | None:
|
||||
if isinstance(marqueur, Marqueur):
|
||||
return marqueur
|
||||
trouves = extraire_marqueurs(f"[^{marqueur}^]")
|
||||
return trouves[0] if trouves else None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Construction de l'index
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def construire() -> Repertoire:
|
||||
"""Charge et indexe l'ensemble des sources du corpus."""
|
||||
repertoire = Repertoire()
|
||||
|
||||
for numero in range(1, 13):
|
||||
dimension = f"dim{numero:02d}"
|
||||
markdown = chemins.dimension(numero).read_text(encoding="utf-8")
|
||||
blocs = blocs_claim.parser(markdown, dimension)
|
||||
notes = notes_bas_page.parser(markdown, dimension)
|
||||
repertoire.blocs_par_dimension[dimension] = blocs
|
||||
repertoire.entrees[dimension] = _indexer(markdown, blocs, notes)
|
||||
|
||||
phase5 = chemins.PHASE5_VALIDATION.read_text(encoding="utf-8")
|
||||
blocs_phase5 = blocs_claim.parser(phase5, "phase5")
|
||||
repertoire.blocs_par_dimension["phase5"] = blocs_phase5
|
||||
repertoire.entrees["phase5"] = _indexer(phase5, blocs_phase5, [])
|
||||
|
||||
markdown_biblio = chemins.BIBLIOGRAPHIE.read_text(encoding="utf-8")
|
||||
repertoire.references = bibliographie.parser(markdown_biblio)
|
||||
repertoire.par_numero_loi = bibliographie.indexer_par_numero(repertoire.references)
|
||||
repertoire.par_affaire_cc = bibliographie.indexer_par_affaire(repertoire.references)
|
||||
repertoire.par_document = bibliographie.indexer_par_document(repertoire.references)
|
||||
repertoire.correspondances = bibliographie.lire_table_de_correspondance(markdown_biblio)
|
||||
|
||||
repertoire.rapprochements = _rapprocher_notes_sans_lien(repertoire)
|
||||
|
||||
avec_lien = sum(
|
||||
1 for entrees in repertoire.entrees.values() for e in entrees.values() if e.source
|
||||
)
|
||||
log.info(
|
||||
"répertoire de citations construit",
|
||||
dimensions=len(repertoire.entrees),
|
||||
entrees=sum(len(e) for e in repertoire.entrees.values()),
|
||||
avec_lien=avec_lien,
|
||||
references=len(repertoire.references),
|
||||
)
|
||||
return repertoire
|
||||
|
||||
|
||||
def _indexer(
|
||||
markdown: str, blocs: list[BlocClaim], notes: list[Note]
|
||||
) -> dict[str, Resolution]:
|
||||
"""Construit l'index d'une dimension selon les cinq conventions du corpus.
|
||||
|
||||
L'ordre d'insertion vaut priorité : une convention n'écrase jamais une
|
||||
entrée déjà posée par une convention plus explicite.
|
||||
"""
|
||||
index: dict[str, Resolution] = {}
|
||||
|
||||
def poser(cle: str, resolution: Resolution) -> None:
|
||||
if cle and cle not in index:
|
||||
index[cle] = resolution
|
||||
|
||||
# 1. Marqueur de fin de claim — la convention la plus explicite.
|
||||
for bloc in blocs:
|
||||
for marqueur in bloc.marqueurs:
|
||||
poser(marqueur, _depuis_bloc(bloc, "bloc"))
|
||||
|
||||
# 2. Numéro porté par l'en-tête du claim (« **Claim 1.2** — … »).
|
||||
for bloc, numero in _numeros_d_entete(markdown, blocs):
|
||||
poser(numero, _depuis_bloc(bloc, "en-tête"))
|
||||
|
||||
# 3. Numéro de section : le premier bloc sourcé de la section en tient lieu.
|
||||
for numero, bloc in _blocs_par_section(markdown, blocs).items():
|
||||
poser(numero, _depuis_bloc(bloc, "section"))
|
||||
|
||||
# 4. Rang du claim dans le fichier (dim03 ne numérote rien d'autre).
|
||||
for rang, bloc in enumerate(blocs, start=1):
|
||||
poser(str(rang), _depuis_bloc(bloc, "rang"))
|
||||
|
||||
# 5. Notes de bas de page — souvent sans URL, mais toujours un libellé.
|
||||
for note in notes:
|
||||
source = note.en_source()
|
||||
if note.numero in index and index[note.numero].source is not None:
|
||||
continue
|
||||
index[note.numero] = Resolution(
|
||||
marqueur=note.numero,
|
||||
source=source,
|
||||
libelle=note.texte or None,
|
||||
origine="note",
|
||||
)
|
||||
|
||||
# 6. Composition « section.rang » : dim11 numérote ses sections mais pas ses
|
||||
# affirmations, et le rapport la cite en `[^dim11-2.1^]` — soit le
|
||||
# premier bloc de la section 2. Convention de dernier recours.
|
||||
for numero_compose, bloc in _blocs_par_section_et_rang(markdown, blocs).items():
|
||||
poser(numero_compose, _depuis_bloc(bloc, "section.rang"))
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def _blocs_par_section_et_rang(
|
||||
markdown: str, blocs: list[BlocClaim]
|
||||
) -> dict[str, BlocClaim]:
|
||||
"""Numérote les blocs d'une section : section 2, 1ᵉʳ bloc → clé « 2.1 »."""
|
||||
lignes = markdown.splitlines()
|
||||
frontieres = [
|
||||
(numero, entete.group(1))
|
||||
for numero, ligne in enumerate(lignes, start=1)
|
||||
if (entete := _ENTETE_NUMEROTEE.match(ligne))
|
||||
]
|
||||
if not frontieres:
|
||||
return {}
|
||||
|
||||
composes: dict[str, BlocClaim] = {}
|
||||
for index, (debut, section) in enumerate(frontieres):
|
||||
if "." in section: # déjà une sous-section, elle est indexée telle quelle
|
||||
continue
|
||||
fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
|
||||
for rang, bloc in enumerate(
|
||||
(b for b in blocs if debut < b.ligne < fin), start=1
|
||||
):
|
||||
composes.setdefault(f"{section}.{rang}", bloc)
|
||||
|
||||
return composes
|
||||
|
||||
|
||||
def _rapprocher_notes_sans_lien(repertoire: Repertoire) -> int:
|
||||
"""Donne une URL aux références qui n'en portent pas dans leur dimension.
|
||||
|
||||
dim09 — la dimension « associations », centrale pour la mission — ne compte
|
||||
que 5 lignes contenant une URL pour 65 notes. Ses sources existent pourtant :
|
||||
la bibliographie consolidée les liste toutes, et la table de correspondance
|
||||
dit lesquelles lui appartiennent.
|
||||
|
||||
Le rapprochement est délibérément prudent : il ne cherche que parmi les
|
||||
références attribuées à la dimension, exige au moins trois mots significatifs
|
||||
communs et un recouvrement d'au moins deux tiers du libellé de la note, et
|
||||
n'accepte le résultat que s'il est **strictement meilleur** que le suivant.
|
||||
Une correspondance ambiguë est refusée plutôt que devinée.
|
||||
"""
|
||||
par_numero = {reference.numero: reference for reference in repertoire.references}
|
||||
rapproches = 0
|
||||
|
||||
for dimension, entrees in repertoire.entrees.items():
|
||||
numeros_autorises = repertoire.correspondances.get(dimension)
|
||||
if not numeros_autorises:
|
||||
continue
|
||||
|
||||
candidates = [
|
||||
(reference, _mots_significatifs(f"{reference.editeur or ''} {reference.titre}"))
|
||||
for numero in sorted(numeros_autorises)
|
||||
if (reference := par_numero.get(numero)) and reference.url
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
for resolution in entrees.values():
|
||||
if resolution.source is not None or not resolution.libelle:
|
||||
continue
|
||||
|
||||
reference = _meilleure_correspondance(resolution.libelle, candidates)
|
||||
if reference is None:
|
||||
continue
|
||||
|
||||
source = reference.en_source()
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
resolution.source = Source(
|
||||
url=source.url,
|
||||
titre=resolution.libelle[:280],
|
||||
editeur=reference.editeur,
|
||||
date_publication=source.date_publication,
|
||||
confiance=Confiance.MEDIUM,
|
||||
marqueur=f"{dimension}-{resolution.marqueur or '?'}",
|
||||
fichier_origine=f"research/lois-2026_{dimension}.md "
|
||||
f"(lien repris de la bibliographie, réf. {reference.numero})",
|
||||
)
|
||||
resolution.origine = "note→bibliographie"
|
||||
rapproches += 1
|
||||
|
||||
log.info("notes sans lien rapprochées de la bibliographie", rapprochements=rapproches)
|
||||
return rapproches
|
||||
|
||||
|
||||
def _mots_significatifs(texte: str) -> set[str]:
|
||||
plat = sans_accents(texte).lower()
|
||||
return {
|
||||
mot
|
||||
for mot in re.split(r"[^a-z0-9]+", plat)
|
||||
if len(mot) > 2 and mot not in _MOTS_VIDES
|
||||
}
|
||||
|
||||
|
||||
def _meilleure_correspondance(
|
||||
libelle: str, candidates: list[tuple[bibliographie.Reference, set[str]]]
|
||||
) -> bibliographie.Reference | None:
|
||||
mots_note = _mots_significatifs(libelle)
|
||||
if len(mots_note) < 3:
|
||||
return None
|
||||
|
||||
scores: list[tuple[float, int, bibliographie.Reference]] = []
|
||||
for reference, mots_reference in candidates:
|
||||
communs = mots_note & mots_reference
|
||||
if len(communs) < 3:
|
||||
continue
|
||||
scores.append((len(communs) / len(mots_note), len(communs), reference))
|
||||
|
||||
if not scores:
|
||||
return None
|
||||
|
||||
scores.sort(key=lambda triplet: (-triplet[0], -triplet[1]))
|
||||
meilleur = scores[0]
|
||||
if meilleur[0] < 0.66:
|
||||
return None
|
||||
# Ambiguïté : deux références également plausibles, on s'abstient.
|
||||
if len(scores) > 1 and abs(scores[1][0] - meilleur[0]) < 1e-9:
|
||||
return None
|
||||
|
||||
return meilleur[2]
|
||||
|
||||
|
||||
def _depuis_bloc(bloc: BlocClaim, origine: str) -> Resolution:
|
||||
sources = bloc.en_sources()
|
||||
return Resolution(
|
||||
marqueur=bloc.marqueurs[0] if bloc.marqueurs else "",
|
||||
source=sources[0] if sources else None,
|
||||
libelle=bloc.affirmation or None,
|
||||
origine=origine,
|
||||
)
|
||||
|
||||
|
||||
def _numeros_d_entete(markdown: str, blocs: list[BlocClaim]) -> list[tuple[BlocClaim, str]]:
|
||||
"""Associe chaque bloc au numéro figurant dans sa ligne d'ouverture."""
|
||||
par_ligne = {bloc.ligne: bloc for bloc in blocs}
|
||||
associations: list[tuple[BlocClaim, str]] = []
|
||||
|
||||
for numero_ligne, ligne in enumerate(markdown.splitlines(), start=1):
|
||||
entete = _ENTETE_CLAIM.match(ligne)
|
||||
if entete and numero_ligne in par_ligne:
|
||||
associations.append((par_ligne[numero_ligne], entete.group(1)))
|
||||
|
||||
return associations
|
||||
|
||||
|
||||
def _blocs_par_section(markdown: str, blocs: list[BlocClaim]) -> dict[str, BlocClaim]:
|
||||
"""Associe un numéro de section au premier bloc sourcé qu'elle contient.
|
||||
|
||||
C'est la seule façon de résoudre ``[^dim11-1.4^]`` : dim11 ne numérote pas
|
||||
ses affirmations, elle numérote ses sections (« ### 1.4. LPM actualisée »).
|
||||
"""
|
||||
lignes = markdown.splitlines()
|
||||
frontieres: list[tuple[int, str]] = []
|
||||
|
||||
for numero_ligne, ligne in enumerate(lignes, start=1):
|
||||
if entete := _ENTETE_NUMEROTEE.match(ligne):
|
||||
frontieres.append((numero_ligne, entete.group(1)))
|
||||
|
||||
if not frontieres:
|
||||
return {}
|
||||
|
||||
par_section: dict[str, BlocClaim] = {}
|
||||
for index, (debut, numero) in enumerate(frontieres):
|
||||
fin = frontieres[index + 1][0] if index + 1 < len(frontieres) else len(lignes) + 1
|
||||
candidats = [b for b in blocs if debut < b.ligne < fin]
|
||||
# Un bloc avec URL vaut mieux qu'un bloc sans, à section égale.
|
||||
avec_lien = next((b for b in candidats if b.urls), None)
|
||||
retenu = avec_lien or (candidats[0] if candidats else None)
|
||||
if retenu is not None and numero not in par_section:
|
||||
par_section[numero] = retenu
|
||||
|
||||
return par_section
|
||||
@@ -13,6 +13,9 @@ from dataclasses import dataclass, field
|
||||
# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^]
|
||||
MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]")
|
||||
|
||||
# Le marqueur avec l'espace qui le précède, pour un retrait sans cicatrice.
|
||||
_MARQUEUR_ET_ESPACE = re.compile(r"[ \t]*(?:\[\^[a-zA-Z0-9]+(?:-[0-9.]+)?\^\])+")
|
||||
|
||||
_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL)
|
||||
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
_CODE = re.compile(r"`([^`]+)`")
|
||||
@@ -114,7 +117,11 @@ def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> str:
|
||||
resultat = _LIEN_MD.sub(r"\1", resultat)
|
||||
resultat = _EMPHASE.sub(r"\1", resultat)
|
||||
if not garder_marqueurs:
|
||||
resultat = MOTIF_MARQUEUR.sub("", resultat)
|
||||
# L'espace qui précède le marqueur est consommée avec lui, sinon le
|
||||
# retrait laisse « … dans les DROM . ». On ne touche surtout pas aux
|
||||
# autres espaces : la typographie française en veut une avant « ; »,
|
||||
# « : », « ! », « ? » et à l'intérieur des guillemets « … ».
|
||||
resultat = _MARQUEUR_ET_ESPACE.sub("", resultat)
|
||||
return " ".join(resultat.split()).strip(" ;,")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Lecture des listes de notes en fin de rapport de dimension.
|
||||
|
||||
Cinq dimensions (dim06, dim08, dim09, dim11, dim12) ne portent pas — ou peu —
|
||||
de blocs sourcés : leurs références vivent dans une liste finale du type ::
|
||||
|
||||
[^1141^] senat.fr — Texte n° 890, projet de loi cohésion républicaine
|
||||
(09/07/2026) — https://www.senat.fr/leg/pjl25-890.html
|
||||
[^44^] diplomatie.gouv.fr, 16/04/2026.
|
||||
|
||||
Toutes ne portent pas d'URL : sur les 258 notes du corpus, 150 en sont
|
||||
dépourvues. Deux cas sont alors distingués :
|
||||
|
||||
- la note cite un identifiant Légifrance (``JORFTEXT…``, ``JORFARTI…``) : l'URL
|
||||
canonique est reconstruite, ce qui n'invente rien puisque le schéma d'URL est
|
||||
fixe et documenté ;
|
||||
- sinon la référence est conservée telle quelle, sans URL, et comptée comme
|
||||
« non résolue » dans le compte-rendu d'import. Elle pourra être rapprochée de
|
||||
la bibliographie consolidée, qui, elle, est intégralement liée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from pipeline.modeles import Confiance, Source
|
||||
from pipeline.parseurs.dates_fr import lire_date
|
||||
from pipeline.parseurs.markdown import nettoyer
|
||||
|
||||
ANNEE_CORPUS = 2026
|
||||
|
||||
_LIGNE_NOTE = re.compile(r"^\[\^([0-9.]+)\^\]\s+(.*)$")
|
||||
_URL = re.compile(r"https?://[^\s;,)\]]+")
|
||||
_IDENTIFIANT_LEGIFRANCE = re.compile(r"\b(JORFTEXT\d{12}|JORFARTI\d{12}|LEGITEXT\d{12})\b")
|
||||
_DATE_PARENTHESES = re.compile(r"\(([^)]*\d{4}[^)]*)\)")
|
||||
|
||||
# Schéma d'URL documenté de Légifrance : un identifiant JORF s'ouvre toujours à
|
||||
# cette adresse. Reconstruire le lien n'ajoute aucune information nouvelle.
|
||||
_GABARIT_LEGIFRANCE = "https://www.legifrance.gouv.fr/jorf/id/{identifiant}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Note:
|
||||
"""Une référence numérotée en fin de rapport de dimension."""
|
||||
|
||||
dimension: str
|
||||
numero: str
|
||||
texte: str
|
||||
url: str | None = None
|
||||
editeur: str | None = None
|
||||
date_publication: date | None = None
|
||||
url_reconstruite: bool = False
|
||||
|
||||
@property
|
||||
def marqueur(self) -> str:
|
||||
return f"{self.dimension}-{self.numero}"
|
||||
|
||||
def en_source(self) -> Source | None:
|
||||
"""Convertit la note en source, ou `None` si elle n'a pas d'URL."""
|
||||
if not self.url:
|
||||
return None
|
||||
return Source(
|
||||
url=self.url,
|
||||
titre=self.texte[:280] or None,
|
||||
editeur=self.editeur,
|
||||
date_publication=self.date_publication,
|
||||
confiance=Confiance.MEDIUM,
|
||||
marqueur=self.marqueur,
|
||||
fichier_origine=f"research/lois-2026_{self.dimension}.md",
|
||||
)
|
||||
|
||||
|
||||
def parser(markdown: str, dimension: str) -> list[Note]:
|
||||
"""Extrait toutes les notes numérotées d'un rapport de dimension."""
|
||||
notes: list[Note] = []
|
||||
|
||||
for ligne in markdown.splitlines():
|
||||
correspondance = _LIGNE_NOTE.match(ligne.strip())
|
||||
if not correspondance:
|
||||
continue
|
||||
notes.append(_lire_note(correspondance.group(1), correspondance.group(2), dimension))
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
def _lire_note(numero: str, corps: str, dimension: str) -> Note:
|
||||
url: str | None = None
|
||||
reconstruite = False
|
||||
|
||||
if trouvee := _URL.search(corps):
|
||||
url = trouvee.group(0).rstrip(".,;)")
|
||||
elif identifiant := _IDENTIFIANT_LEGIFRANCE.search(corps):
|
||||
url = _GABARIT_LEGIFRANCE.format(identifiant=identifiant.group(1))
|
||||
reconstruite = True
|
||||
|
||||
return Note(
|
||||
dimension=dimension,
|
||||
numero=numero,
|
||||
texte=nettoyer(_sans_url(corps)),
|
||||
url=url,
|
||||
editeur=_lire_editeur(corps),
|
||||
date_publication=_lire_date_note(corps),
|
||||
url_reconstruite=reconstruite,
|
||||
)
|
||||
|
||||
|
||||
def _sans_url(corps: str) -> str:
|
||||
return _URL.sub("", corps).strip(" —–-:.,")
|
||||
|
||||
|
||||
def _lire_editeur(corps: str) -> str | None:
|
||||
"""L'éditeur ouvre la note, avant le tiret cadratin ou la première virgule."""
|
||||
debut = _sans_url(corps)
|
||||
for separateur in ("—", "–", " : ", ","):
|
||||
if separateur in debut:
|
||||
debut = debut.split(separateur, 1)[0]
|
||||
break
|
||||
debut = nettoyer(debut).strip(" .")
|
||||
return debut[:120] or None
|
||||
|
||||
|
||||
def _lire_date_note(corps: str) -> date | None:
|
||||
"""Date entre parenthèses en priorité, sinon première date du corps."""
|
||||
for parenthese in _DATE_PARENTHESES.findall(corps):
|
||||
if lue := lire_date(parenthese, annee_defaut=ANNEE_CORPUS):
|
||||
return lue
|
||||
return lire_date(_sans_url(corps), annee_defaut=ANNEE_CORPUS)
|
||||
@@ -228,6 +228,11 @@ class ResultatSeed:
|
||||
|
||||
textes: list[Texte] = field(default_factory=list)
|
||||
avertissements: list[str] = field(default_factory=list)
|
||||
# Marqueurs de citation portés par la ligne d'annexe de chaque texte.
|
||||
# Les lignes éclatées transmettent les leurs aux deux textes issus d'elles :
|
||||
# sans cela, l'accord « plateforme centrale » MACF n'hériterait d'aucune
|
||||
# source, alors que sa ligne d'origine en cite trois.
|
||||
marqueurs: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.textes)
|
||||
@@ -249,7 +254,9 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
else:
|
||||
for ligne in tableau_a.lignes:
|
||||
try:
|
||||
resultat.textes.append(_lire_loi_promulguee(ligne))
|
||||
texte = _lire_loi_promulguee(ligne)
|
||||
resultat.textes.append(texte)
|
||||
resultat.marqueurs[texte.id] = _marqueurs_de_ligne(ligne)
|
||||
except Exception as erreur: # noqa: BLE001 — on continue le lot
|
||||
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
|
||||
|
||||
@@ -258,7 +265,11 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
else:
|
||||
for ligne in tableau_b.lignes:
|
||||
try:
|
||||
resultat.textes.extend(_lire_texte_en_cours(ligne))
|
||||
issus = _lire_texte_en_cours(ligne)
|
||||
marqueurs = _marqueurs_de_ligne(ligne)
|
||||
resultat.textes.extend(issus)
|
||||
for texte in issus:
|
||||
resultat.marqueurs[texte.id] = list(marqueurs)
|
||||
except Exception as erreur: # noqa: BLE001
|
||||
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
|
||||
|
||||
@@ -272,6 +283,16 @@ def parser(markdown: str) -> ResultatSeed:
|
||||
return resultat
|
||||
|
||||
|
||||
def _marqueurs_de_ligne(ligne: dict[str, str]) -> list[str]:
|
||||
"""Marqueurs de citation portés par une ligne, toutes colonnes confondues."""
|
||||
marqueurs: list[str] = []
|
||||
for cellule in ligne.values():
|
||||
for marqueur in extraire_marqueurs(cellule):
|
||||
if marqueur.brut not in marqueurs:
|
||||
marqueurs.append(marqueur.brut)
|
||||
return marqueurs
|
||||
|
||||
|
||||
def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
|
||||
for tableau in tableaux:
|
||||
if tableau.entetes and tableau.entetes[0] == premiere_colonne:
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Remplissage initial de la base à partir du corpus de recherche.
|
||||
|
||||
Enchaînement :
|
||||
|
||||
1. **Annexe** (`sec10.md`) — 52 textes avec statut, dates, cotation des
|
||||
libertés et pertinence Guadeloupe pour les lois promulguées.
|
||||
2. **Citations** — index de résolution des marqueurs `[^dimXX-N^]` vers les
|
||||
sources vérifiables des douze rapports de dimension.
|
||||
3. **Chapitres** (`sec00` → `sec09`) — résumés, points clés et marqueurs
|
||||
supplémentaires, rattachés phrase par phrase.
|
||||
4. **Bibliographie** — filet de sécurité : toute loi promulguée reçoit les
|
||||
références consolidées qui citent son numéro.
|
||||
5. **Analyses** (`insight.md`) — sept lectures transversales.
|
||||
|
||||
Rien n'est écrit qui ne vienne d'un fichier de `data/input/`, lequel n'est
|
||||
jamais modifié. Le compte-rendu d'import indique fichier par fichier ce qui a
|
||||
été lu, ce qui a été rattaché et ce qui manque.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pipeline import chemins, db, guadeloupe
|
||||
from pipeline.journal import configurer, logger
|
||||
from pipeline.modeles import Source, Texte
|
||||
from pipeline.parseurs import (
|
||||
analyses,
|
||||
bibliographie,
|
||||
chapitres,
|
||||
citations,
|
||||
tableaux_sec10,
|
||||
)
|
||||
|
||||
log = logger("seed")
|
||||
|
||||
# Plancher fixé au §2 du cahier des charges.
|
||||
TEXTES_ATTENDUS_MINIMUM = 49
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompteRendu:
|
||||
"""Ce que le seed a lu, écrit et manqué."""
|
||||
|
||||
textes: int = 0
|
||||
sources: int = 0
|
||||
evenements: int = 0
|
||||
decisions_cc: int = 0
|
||||
analyses: int = 0
|
||||
|
||||
textes_sans_source: list[str] = field(default_factory=list)
|
||||
textes_sans_resume: list[str] = field(default_factory=list)
|
||||
pertinences_deduites: list[str] = field(default_factory=list)
|
||||
avertissements: list[str] = field(default_factory=list)
|
||||
|
||||
marqueurs_avec_lien: int = 0
|
||||
marqueurs_sans_lien: int = 0
|
||||
marqueurs_non_resolus: int = 0
|
||||
|
||||
def en_texte(self) -> str:
|
||||
lignes = [
|
||||
"Compte-rendu d'import du corpus de recherche",
|
||||
"=" * 44,
|
||||
f" textes : {self.textes}",
|
||||
f" sources : {self.sources}",
|
||||
f" événements : {self.evenements}",
|
||||
f" décisions CC : {self.decisions_cc}",
|
||||
f" analyses : {self.analyses}",
|
||||
"",
|
||||
"Résolution des citations",
|
||||
f" avec lien : {self.marqueurs_avec_lien}",
|
||||
f" sans lien (réf.) : {self.marqueurs_sans_lien}",
|
||||
f" non résolus : {self.marqueurs_non_resolus}",
|
||||
"",
|
||||
"Points d'attention",
|
||||
f" textes sans source URL : {len(self.textes_sans_source)}"
|
||||
+ (f" → {', '.join(self.textes_sans_source)}" if self.textes_sans_source else ""),
|
||||
f" textes sans résumé : {len(self.textes_sans_resume)}"
|
||||
+ (f" → {', '.join(self.textes_sans_resume)}" if self.textes_sans_resume else ""),
|
||||
f" pertinences déduites : {len(self.pertinences_deduites)}",
|
||||
]
|
||||
if self.avertissements:
|
||||
lignes.append("")
|
||||
lignes.append("Avertissements")
|
||||
lignes.extend(f" ! {a}" for a in self.avertissements)
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def ensemencer(cx: sqlite3.Connection) -> CompteRendu:
|
||||
"""Construit le jeu de données initial et l'écrit en base."""
|
||||
compte = CompteRendu()
|
||||
|
||||
manquants = chemins.verifier_corpus()
|
||||
if manquants:
|
||||
compte.avertissements.extend(f"fichier d'entrée absent : {m}" for m in manquants)
|
||||
|
||||
# 1. Annexe récapitulative — le socle factuel.
|
||||
annexe = tableaux_sec10.parser(chemins.chapitre(10).read_text(encoding="utf-8"))
|
||||
compte.avertissements.extend(annexe.avertissements)
|
||||
textes: dict[str, Texte] = {texte.id: texte for texte in annexe.textes}
|
||||
marqueurs_annexe = annexe.marqueurs
|
||||
|
||||
# 2. Index de résolution des citations.
|
||||
repertoire = citations.construire()
|
||||
|
||||
# 3. Chapitres rédigés.
|
||||
lecture = chapitres.parser()
|
||||
|
||||
# 4. Enrichissement texte par texte.
|
||||
for texte in textes.values():
|
||||
_rattacher_sources(texte, repertoire, lecture, marqueurs_annexe)
|
||||
_rattacher_redaction(texte, lecture)
|
||||
_coter_guadeloupe(texte, compte)
|
||||
|
||||
if not texte.sources:
|
||||
compte.textes_sans_source.append(texte.id)
|
||||
if not texte.resume:
|
||||
compte.textes_sans_resume.append(texte.id)
|
||||
texte.a_verifier = True
|
||||
texte.motif_verification = _ajouter_motif(
|
||||
texte.motif_verification,
|
||||
"Aucun paragraphe du rapport ne développe ce texte : "
|
||||
"seul l'essentiel de l'annexe est disponible.",
|
||||
)
|
||||
|
||||
compte.marqueurs_avec_lien = len(repertoire.resolus_avec_lien)
|
||||
compte.marqueurs_sans_lien = len(repertoire.resolus_sans_lien)
|
||||
compte.marqueurs_non_resolus = len(repertoire.non_resolus)
|
||||
|
||||
# 5. Écriture.
|
||||
db.enregistrer_textes(cx, textes.values())
|
||||
|
||||
lectures = analyses.parser(chemins.INSIGHTS.read_text(encoding="utf-8"))
|
||||
with db.transaction(cx):
|
||||
for analyse in lectures:
|
||||
db.enregistrer_insight(cx, analyse)
|
||||
|
||||
statistiques = db.statistiques(cx)
|
||||
compte.textes = statistiques["textes"]
|
||||
compte.sources = statistiques["sources"]
|
||||
compte.evenements = statistiques["evenements"]
|
||||
compte.decisions_cc = statistiques["decisions_cc"]
|
||||
compte.analyses = statistiques["insights"]
|
||||
|
||||
if compte.textes < TEXTES_ATTENDUS_MINIMUM:
|
||||
compte.avertissements.append(
|
||||
f"{compte.textes} textes importés, {TEXTES_ATTENDUS_MINIMUM} attendus au minimum"
|
||||
)
|
||||
|
||||
return compte
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Enrichissement
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _rattacher_sources(
|
||||
texte: Texte,
|
||||
repertoire: citations.Repertoire,
|
||||
lecture: chapitres.LectureChapitres,
|
||||
marqueurs_annexe: dict[str, list[str]],
|
||||
) -> None:
|
||||
"""Attache à un texte toutes les sources que le corpus lui rattache."""
|
||||
# Marqueurs portés par sa ligne d'annexe, puis par les chapitres.
|
||||
marqueurs = list(marqueurs_annexe.get(texte.id, []))
|
||||
marqueurs.extend(lecture.marqueurs_de(texte.id))
|
||||
|
||||
sources = repertoire.resoudre_plusieurs(marqueurs)
|
||||
|
||||
# Filet : la bibliographie consolidée. Trois clés d'accroche, du plus
|
||||
# spécifique au moins spécifique — un texte non promulgué n'a pas de numéro
|
||||
# de loi, sa seule identité est sa cote parlementaire (« AN n° 3025 »).
|
||||
affaires = [decision.numero_affaire for decision in texte.decisions_cc]
|
||||
documents = bibliographie.numeros_de_document(
|
||||
f"{texte.titre_court} {texte.titre_officiel or ''}"
|
||||
)
|
||||
sources.extend(
|
||||
repertoire.references_du_texte(
|
||||
numero_loi=texte.numero_officiel, affaires=affaires, documents=documents
|
||||
)
|
||||
)
|
||||
|
||||
vues: set[str] = set()
|
||||
retenues: list[Source] = []
|
||||
for source in sources:
|
||||
if source.url in vues:
|
||||
continue
|
||||
vues.add(source.url)
|
||||
retenues.append(source)
|
||||
|
||||
texte.sources = retenues
|
||||
|
||||
|
||||
def _rattacher_redaction(texte: Texte, lecture: chapitres.LectureChapitres) -> None:
|
||||
"""Reprend résumé et points clés du rapport, sans les reformuler."""
|
||||
if resume := lecture.resume_de(texte.id):
|
||||
texte.resume = resume
|
||||
|
||||
# Les points clés de l'annexe (colonne « Enjeu libertés ») sont déjà posés
|
||||
# par le parseur de tableaux ; on complète avec la note de régime.
|
||||
if texte.regime_libertes_note and texte.regime_libertes_note not in texte.points_cles:
|
||||
texte.points_cles.append(texte.regime_libertes_note)
|
||||
if texte.guadeloupe_note and texte.guadeloupe_note not in texte.points_cles:
|
||||
texte.points_cles.append(texte.guadeloupe_note)
|
||||
|
||||
|
||||
def _coter_guadeloupe(texte: Texte, compte: CompteRendu) -> None:
|
||||
"""Cote la pertinence guadeloupéenne des textes que l'annexe ne cote pas.
|
||||
|
||||
Les 27 lois promulguées portent la cotation manuelle du rapport : elle est
|
||||
conservée telle quelle, conformément au §5.3 du cahier des charges.
|
||||
"""
|
||||
if texte.guadeloupe_pertinence is not None:
|
||||
return
|
||||
|
||||
cotation = guadeloupe.coter(
|
||||
texte.titre_court,
|
||||
texte.titre_officiel,
|
||||
texte.resume,
|
||||
" ".join(texte.points_cles),
|
||||
texte.guadeloupe_note,
|
||||
)
|
||||
if cotation.pertinence is None:
|
||||
return
|
||||
|
||||
texte.guadeloupe_pertinence = cotation.pertinence
|
||||
texte.guadeloupe_note = texte.guadeloupe_note or cotation.note
|
||||
texte.a_verifier = True
|
||||
texte.motif_verification = _ajouter_motif(
|
||||
texte.motif_verification, guadeloupe.MOTIF_VERIFICATION
|
||||
)
|
||||
compte.pertinences_deduites.append(texte.id)
|
||||
|
||||
|
||||
def _ajouter_motif(existant: str | None, nouveau: str) -> str:
|
||||
if not existant:
|
||||
return nouveau
|
||||
if nouveau in existant:
|
||||
return existant
|
||||
return f"{existant} {nouveau}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Ligne de commande
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
analyseur = argparse.ArgumentParser(
|
||||
description="Remplit la base à partir du corpus de recherche de data/input/."
|
||||
)
|
||||
analyseur.add_argument(
|
||||
"--repartir-de-zero",
|
||||
action="store_true",
|
||||
help="supprime la base existante avant l'import",
|
||||
)
|
||||
analyseur.add_argument("--verbeux", action="store_true", help="journalisation détaillée")
|
||||
arguments = analyseur.parse_args()
|
||||
|
||||
configurer("DEBUG" if arguments.verbeux else "INFO")
|
||||
depart = time.monotonic()
|
||||
|
||||
cx = db.initialiser(repartir_de_zero=arguments.repartir_de_zero)
|
||||
run_id = db.ouvrir_run(cx, "seed")
|
||||
compte = ensemencer(cx)
|
||||
duree = time.monotonic() - depart
|
||||
|
||||
db.cloturer_run(
|
||||
cx,
|
||||
run_id,
|
||||
duree_s=duree,
|
||||
ajouts=compte.textes,
|
||||
echecs=len(compte.avertissements),
|
||||
alertes=compte.avertissements,
|
||||
rapport=compte.en_texte(),
|
||||
)
|
||||
|
||||
print()
|
||||
print(compte.en_texte())
|
||||
print()
|
||||
log.info(
|
||||
"seed terminé",
|
||||
duree_s=round(duree, 2),
|
||||
textes=compte.textes,
|
||||
sources=compte.sources,
|
||||
base=str(chemins.BASE_SQLITE),
|
||||
)
|
||||
|
||||
if compte.textes < TEXTES_ATTENDUS_MINIMUM:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Blocs sourcés, notes, bibliographie et résolution des marqueurs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from pipeline import chemins
|
||||
from pipeline.modeles import Confiance
|
||||
from pipeline.parseurs import bibliographie, blocs_claim, citations, notes_bas_page
|
||||
from pipeline.parseurs.markdown import extraire_marqueurs
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Blocs sourcés — les quatre dialectes du corpus
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DIALECTE_A = """\
|
||||
Claim: La loi n° 2026-350 est publiée au JORF du 10 mai 2026 [^1^].
|
||||
Source: Légifrance
|
||||
URL: https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054049780
|
||||
Date: 10 mai 2026
|
||||
Excerpt: « visant à garantir le droit de visite »
|
||||
Confidence: high
|
||||
"""
|
||||
|
||||
DIALECTE_B = """\
|
||||
**Claim 3 :** Le gouvernement a élargi le texte par lettre rectificative [^3^]
|
||||
Source: DHnet / AFP
|
||||
URL: https://www.dhnet.be/exemple
|
||||
Date: 1er juillet 2026
|
||||
Confidence: high
|
||||
"""
|
||||
|
||||
DIALECTE_C = """\
|
||||
**Claim 1.2** — Le Conseil d'État confirme que le texte comporte 23 articles.
|
||||
- **Source** : Conseil d'État
|
||||
- **URL** : https://www.conseil-etat.fr/avis ; https://www.senat.fr/rap/exemple.html
|
||||
- **Date** : 2 avril 2026
|
||||
- **Confidence** : Haute
|
||||
"""
|
||||
|
||||
DIALECTE_PHASE5 = """\
|
||||
**Claim 1.1 (décisif, source primaire) : le texte fixe l'AFD à 500 €.**
|
||||
- **Source :** Assemblée nationale, TEXTE ADOPTÉ n° 340
|
||||
- **URL :** https://www.assemblee-nationale.fr/dyn/17/textes/l17t0340_texte-adopte-provisoire
|
||||
- **Date :** 21/07/2026
|
||||
- **Confidence : très élevée**
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "urls_attendues"),
|
||||
[
|
||||
(DIALECTE_A, 1),
|
||||
(DIALECTE_B, 1),
|
||||
(DIALECTE_C, 2), # deux URLs séparées par « ; » sur la même ligne
|
||||
(DIALECTE_PHASE5, 1),
|
||||
],
|
||||
)
|
||||
def test_les_quatre_dialectes_sont_lus(source: str, urls_attendues: int) -> None:
|
||||
(bloc,) = blocs_claim.parser(source, "dimXX")
|
||||
assert len(bloc.urls) == urls_attendues
|
||||
assert bloc.confiance is Confiance.HIGH
|
||||
|
||||
|
||||
def test_dialecte_a_complet() -> None:
|
||||
(bloc,) = blocs_claim.parser(DIALECTE_A, "dim01")
|
||||
assert bloc.marqueurs == ["1"]
|
||||
assert bloc.editeur == "Légifrance"
|
||||
assert bloc.date_publication == date(2026, 5, 10)
|
||||
assert bloc.extrait == "visant à garantir le droit de visite"
|
||||
(source,) = bloc.en_sources()
|
||||
assert source.tier == "T1" # legifrance.gouv.fr reconnu comme primaire
|
||||
assert source.marqueur == "dim01-1"
|
||||
|
||||
|
||||
def test_phase5_sans_la_parenthese_ne_serait_pas_lu() -> None:
|
||||
"""La forme « **Claim 1.1 (décisif…) : … » est propre à la validation."""
|
||||
blocs = blocs_claim.parser(DIALECTE_PHASE5, "phase5")
|
||||
assert len(blocs) == 1
|
||||
assert blocs[0].confiance is Confiance.HIGH
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valeur", "attendu"),
|
||||
[
|
||||
("high", Confiance.HIGH),
|
||||
("Haute", Confiance.HIGH),
|
||||
("très élevée", Confiance.HIGH),
|
||||
("élevée", Confiance.HIGH),
|
||||
("medium", Confiance.MEDIUM),
|
||||
("medium-high", Confiance.MEDIUM),
|
||||
("low", Confiance.LOW),
|
||||
("valeur inconnue", Confiance.MEDIUM),
|
||||
],
|
||||
)
|
||||
def test_lecture_de_la_confiance(valeur: str, attendu: Confiance) -> None:
|
||||
assert blocs_claim.lire_confiance(valeur) is attendu
|
||||
|
||||
|
||||
def test_tous_les_rapports_de_dimension_rendent_des_blocs() -> None:
|
||||
"""Aucune dimension ne doit être muette : ce serait un dialecte manqué."""
|
||||
for numero in range(1, 13):
|
||||
markdown = chemins.dimension(numero).read_text(encoding="utf-8")
|
||||
blocs = blocs_claim.parser(markdown, f"dim{numero:02d}")
|
||||
assert blocs, f"dim{numero:02d} n'a produit aucun bloc"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Notes de bas de page
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_note_avec_url() -> None:
|
||||
ligne = "[^1141^] senat.fr — Texte n° 890 (09/07/2026) — https://www.senat.fr/leg/pjl25-890.html"
|
||||
(note,) = notes_bas_page.parser(ligne, "dim09")
|
||||
assert note.numero == "1141"
|
||||
assert note.url == "https://www.senat.fr/leg/pjl25-890.html"
|
||||
assert note.editeur == "senat.fr"
|
||||
assert note.date_publication == date(2026, 7, 9)
|
||||
assert note.en_source() is not None
|
||||
|
||||
|
||||
def test_note_sans_url_reste_sans_source() -> None:
|
||||
(note,) = notes_bas_page.parser("[^44^] diplomatie.gouv.fr, 16/04/2026.", "dim01")
|
||||
assert note.url is None
|
||||
assert note.en_source() is None
|
||||
assert note.texte # le libellé est conservé
|
||||
|
||||
|
||||
def test_url_legifrance_reconstruite_depuis_un_identifiant_jorf() -> None:
|
||||
"""Le schéma d'URL de Légifrance est fixe : le reconstruire n'invente rien."""
|
||||
(note,) = notes_bas_page.parser(
|
||||
"[^1^] Légifrance, JORFTEXT000054049780 (loi 2026-350).", "dim01"
|
||||
)
|
||||
assert note.url == "https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054049780"
|
||||
assert note.url_reconstruite is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Bibliographie
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bibliographie_entierement_liee() -> None:
|
||||
references = bibliographie.parser(chemins.BIBLIOGRAPHIE.read_text(encoding="utf-8"))
|
||||
assert len(references) == 481
|
||||
assert all(r.url for r in references), "la bibliographie doit être intégralement liée"
|
||||
|
||||
|
||||
def test_numero_de_loi_translittere() -> None:
|
||||
(reference,) = bibliographie.parser(
|
||||
"[1] Aida. Loi ndeg 2026 554 290626 visant a relancer [EB/OL]. 29 juin 2026. https://x.fr/a"
|
||||
)
|
||||
assert "2026-554" in reference.numeros_cites
|
||||
|
||||
|
||||
def test_les_plages_de_pages_ne_font_pas_des_numeros_de_loi() -> None:
|
||||
(reference,) = bibliographie.parser(
|
||||
"[2] Sénat. Programmation militaire 2024-2030 [EB/OL]. 2026. https://senat.fr/x"
|
||||
)
|
||||
assert "2024-2030" not in reference.numeros_cites
|
||||
|
||||
|
||||
def test_cotes_de_documents_parlementaires() -> None:
|
||||
assert bibliographie.numeros_de_document("L17b3025 proposition loi") == ["3025"]
|
||||
assert bibliographie.numeros_de_document("ppl25-691") == ["691"]
|
||||
assert bibliographie.numeros_de_document("Texte n° 890") == ["890"]
|
||||
|
||||
|
||||
def test_table_de_correspondance_des_prefixes() -> None:
|
||||
"""Les plages « 5–6 » doivent être dépliées."""
|
||||
correspondances = bibliographie.lire_table_de_correspondance(
|
||||
chemins.BIBLIOGRAPHIE.read_text(encoding="utf-8")
|
||||
)
|
||||
assert set(correspondances) >= {f"dim{n:02d}" for n in range(1, 13)} | {"phase5"}
|
||||
assert 5 in correspondances["dim01"] and 6 in correspondances["dim01"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Résolution des marqueurs
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def repertoire() -> citations.Repertoire:
|
||||
return citations.construire()
|
||||
|
||||
|
||||
def test_tous_les_marqueurs_du_rapport_sont_resolus(repertoire) -> None:
|
||||
"""Aucun marqueur du rapport ne doit rester orphelin."""
|
||||
repertoire.remettre_a_zero_diagnostic()
|
||||
|
||||
marqueurs = {}
|
||||
for numero in range(11):
|
||||
for marqueur in extraire_marqueurs(
|
||||
chemins.chapitre(numero).read_text(encoding="utf-8")
|
||||
):
|
||||
marqueurs[marqueur.brut] = marqueur
|
||||
|
||||
for marqueur in marqueurs.values():
|
||||
repertoire.resoudre(marqueur)
|
||||
|
||||
assert repertoire.non_resolus == []
|
||||
assert repertoire.taux_de_resolution == 1.0
|
||||
# La très grande majorité doit aboutir à une URL, pas seulement à un libellé.
|
||||
assert len(repertoire.resolus_avec_lien) / len(marqueurs) > 0.90
|
||||
|
||||
|
||||
def test_resolution_d_un_marqueur_de_dimension(repertoire) -> None:
|
||||
source = repertoire.resoudre("dim07-24")
|
||||
assert source is not None
|
||||
assert source.url.startswith("http")
|
||||
assert source.marqueur == "dim07-24"
|
||||
|
||||
|
||||
def test_marqueurs_insight_sans_source_par_construction(repertoire) -> None:
|
||||
"""La bibliographie indique qu'insight n'a « aucune source primaire propre »."""
|
||||
repertoire.remettre_a_zero_diagnostic()
|
||||
assert repertoire.resoudre("insight-5") is None
|
||||
assert repertoire.non_resolus == []
|
||||
|
||||
|
||||
def test_notes_sans_lien_rapprochees_de_la_bibliographie(repertoire) -> None:
|
||||
assert repertoire.rapprochements > 0
|
||||
Reference in New Issue
Block a user