From c28243cf871e7dc3530416d313da531e60de476e Mon Sep 17 00:00:00 2001 From: Cyber Mawonaj Date: Sat, 25 Jul 2026 19:43:03 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20:=20sources,=20citations=20et=20see?= =?UTF-8?q?d=20complet=20=E2=80=94=20368=20sources,=200=20texte=20orphelin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pipeline/config.yaml | 113 +++++++ pipeline/guadeloupe.py | 92 ++++++ pipeline/parseurs/analyses.py | 91 ++++++ pipeline/parseurs/bibliographie.py | 234 ++++++++++++++ pipeline/parseurs/blocs_claim.py | 217 +++++++++++++ pipeline/parseurs/chapitres.py | 222 ++++++++++++++ pipeline/parseurs/citations.py | 460 ++++++++++++++++++++++++++++ pipeline/parseurs/markdown.py | 9 +- pipeline/parseurs/notes_bas_page.py | 128 ++++++++ pipeline/parseurs/tableaux_sec10.py | 25 +- pipeline/seed_from_research.py | 294 ++++++++++++++++++ tests/test_citations.py | 226 ++++++++++++++ 12 files changed, 2108 insertions(+), 3 deletions(-) create mode 100644 pipeline/config.yaml create mode 100644 pipeline/guadeloupe.py create mode 100644 pipeline/parseurs/analyses.py create mode 100644 pipeline/parseurs/bibliographie.py create mode 100644 pipeline/parseurs/blocs_claim.py create mode 100644 pipeline/parseurs/chapitres.py create mode 100644 pipeline/parseurs/citations.py create mode 100644 pipeline/parseurs/notes_bas_page.py create mode 100644 pipeline/seed_from_research.py create mode 100644 tests/test_citations.py diff --git a/pipeline/config.yaml b/pipeline/config.yaml new file mode 100644 index 0000000..6432e06 --- /dev/null +++ b/pipeline/config.yaml @@ -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 diff --git a/pipeline/guadeloupe.py b/pipeline/guadeloupe.py new file mode 100644 index 0000000..9e3b74b --- /dev/null +++ b/pipeline/guadeloupe.py @@ -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"(? 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." +) diff --git a/pipeline/parseurs/analyses.py b/pipeline/parseurs/analyses.py new file mode 100644 index 0000000..0479d54 --- /dev/null +++ b/pipeline/parseurs/analyses.py @@ -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. + + **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() diff --git a/pipeline/parseurs/bibliographie.py b/pipeline/parseurs/bibliographie.py new file mode 100644 index 0000000..b9a4712 --- /dev/null +++ b/pipeline/parseurs/bibliographie.py @@ -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 diff --git a/pipeline/parseurs/blocs_claim.py b/pipeline/parseurs/blocs_claim.py new file mode 100644 index 0000000..d23d5db --- /dev/null +++ b/pipeline/parseurs/blocs_claim.py @@ -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 diff --git a/pipeline/parseurs/chapitres.py b/pipeline/parseurs/chapitres.py new file mode 100644 index 0000000..d381e9c --- /dev/null +++ b/pipeline/parseurs/chapitres.py @@ -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 diff --git a/pipeline/parseurs/citations.py b/pipeline/parseurs/citations.py new file mode 100644 index 0000000..bb77577 --- /dev/null +++ b/pipeline/parseurs/citations.py @@ -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 diff --git a/pipeline/parseurs/markdown.py b/pipeline/parseurs/markdown.py index 182e261..be16074 100644 --- a/pipeline/parseurs/markdown.py +++ b/pipeline/parseurs/markdown.py @@ -13,6 +13,9 @@ from dataclasses import dataclass, field # Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^] MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]") +# Le marqueur avec l'espace qui le précède, pour un retrait sans cicatrice. +_MARQUEUR_ET_ESPACE = re.compile(r"[ \t]*(?:\[\^[a-zA-Z0-9]+(?:-[0-9.]+)?\^\])+") + _EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL) _LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _CODE = re.compile(r"`([^`]+)`") @@ -114,7 +117,11 @@ def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> str: resultat = _LIEN_MD.sub(r"\1", resultat) resultat = _EMPHASE.sub(r"\1", resultat) if not garder_marqueurs: - resultat = MOTIF_MARQUEUR.sub("", resultat) + # L'espace qui précède le marqueur est consommée avec lui, sinon le + # retrait laisse « … dans les DROM . ». On ne touche surtout pas aux + # autres espaces : la typographie française en veut une avant « ; », + # « : », « ! », « ? » et à l'intérieur des guillemets « … ». + resultat = _MARQUEUR_ET_ESPACE.sub("", resultat) return " ".join(resultat.split()).strip(" ;,") diff --git a/pipeline/parseurs/notes_bas_page.py b/pipeline/parseurs/notes_bas_page.py new file mode 100644 index 0000000..335c863 --- /dev/null +++ b/pipeline/parseurs/notes_bas_page.py @@ -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) diff --git a/pipeline/parseurs/tableaux_sec10.py b/pipeline/parseurs/tableaux_sec10.py index 1de3df7..08ffd58 100644 --- a/pipeline/parseurs/tableaux_sec10.py +++ b/pipeline/parseurs/tableaux_sec10.py @@ -228,6 +228,11 @@ class ResultatSeed: textes: list[Texte] = field(default_factory=list) avertissements: list[str] = field(default_factory=list) + # Marqueurs de citation portés par la ligne d'annexe de chaque texte. + # Les lignes éclatées transmettent les leurs aux deux textes issus d'elles : + # sans cela, l'accord « plateforme centrale » MACF n'hériterait d'aucune + # source, alors que sa ligne d'origine en cite trois. + marqueurs: dict[str, list[str]] = field(default_factory=dict) def __len__(self) -> int: return len(self.textes) @@ -249,7 +254,9 @@ def parser(markdown: str) -> ResultatSeed: else: for ligne in tableau_a.lignes: try: - resultat.textes.append(_lire_loi_promulguee(ligne)) + texte = _lire_loi_promulguee(ligne) + resultat.textes.append(texte) + resultat.marqueurs[texte.id] = _marqueurs_de_ligne(ligne) except Exception as erreur: # noqa: BLE001 — on continue le lot resultat.avertissements.append(f"Tableau A, ligne {ligne!r} : {erreur}") @@ -258,7 +265,11 @@ def parser(markdown: str) -> ResultatSeed: else: for ligne in tableau_b.lignes: try: - resultat.textes.extend(_lire_texte_en_cours(ligne)) + issus = _lire_texte_en_cours(ligne) + marqueurs = _marqueurs_de_ligne(ligne) + resultat.textes.extend(issus) + for texte in issus: + resultat.marqueurs[texte.id] = list(marqueurs) except Exception as erreur: # noqa: BLE001 resultat.avertissements.append(f"Tableau B, ligne {ligne!r} : {erreur}") @@ -272,6 +283,16 @@ def parser(markdown: str) -> ResultatSeed: return resultat +def _marqueurs_de_ligne(ligne: dict[str, str]) -> list[str]: + """Marqueurs de citation portés par une ligne, toutes colonnes confondues.""" + marqueurs: list[str] = [] + for cellule in ligne.values(): + for marqueur in extraire_marqueurs(cellule): + if marqueur.brut not in marqueurs: + marqueurs.append(marqueur.brut) + return marqueurs + + def _trouver(tableaux: list[Tableau], premiere_colonne: str) -> Tableau | None: for tableau in tableaux: if tableau.entetes and tableau.entetes[0] == premiere_colonne: diff --git a/pipeline/seed_from_research.py b/pipeline/seed_from_research.py new file mode 100644 index 0000000..5bd1d4e --- /dev/null +++ b/pipeline/seed_from_research.py @@ -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() diff --git a/tests/test_citations.py b/tests/test_citations.py new file mode 100644 index 0000000..58fc47c --- /dev/null +++ b/tests/test_citations.py @@ -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