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>
227 lines
9.3 KiB
Python
227 lines
9.3 KiB
Python
"""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
|