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^]
|
# Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^]
|
||||||
MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]")
|
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)
|
_EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL)
|
||||||
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
_LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||||
_CODE = 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 = _LIEN_MD.sub(r"\1", resultat)
|
||||||
resultat = _EMPHASE.sub(r"\1", resultat)
|
resultat = _EMPHASE.sub(r"\1", resultat)
|
||||||
if not garder_marqueurs:
|
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(" ;,")
|
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)
|
textes: list[Texte] = field(default_factory=list)
|
||||||
avertissements: list[str] = 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:
|
def __len__(self) -> int:
|
||||||
return len(self.textes)
|
return len(self.textes)
|
||||||
@@ -249,7 +254,9 @@ def parser(markdown: str) -> ResultatSeed:
|
|||||||
else:
|
else:
|
||||||
for ligne in tableau_a.lignes:
|
for ligne in tableau_a.lignes:
|
||||||
try:
|
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
|
except Exception as erreur: # noqa: BLE001 — on continue le lot
|
||||||
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
|
resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}")
|
||||||
|
|
||||||
@@ -258,7 +265,11 @@ def parser(markdown: str) -> ResultatSeed:
|
|||||||
else:
|
else:
|
||||||
for ligne in tableau_b.lignes:
|
for ligne in tableau_b.lignes:
|
||||||
try:
|
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
|
except Exception as erreur: # noqa: BLE001
|
||||||
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
|
resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}")
|
||||||
|
|
||||||
@@ -272,6 +283,16 @@ def parser(markdown: str) -> ResultatSeed:
|
|||||||
return resultat
|
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:
|
def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None:
|
||||||
for tableau in tableaux:
|
for tableau in tableaux:
|
||||||
if tableau.entetes and tableau.entetes[0] == premiere_colonne:
|
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