Collecteurs, chacun isolé et tolérant à l'échec : - legifrance : OAuth PISTE validé en production. Deux pièges vérifiés sur le compte réel — seul le couple « Client ID / Client secret » ouvre un jeton (la clé d'API donne invalid_client), et les identifiants de production ne passent pas en bac à sable. - senat : liste chronologique des lois promulguées. L'URL du §5.1 (senat.fr/lois/index.html) est morte ; l'équivalent fonctionnel est /dossiers-legislatifs/lois-promulguees.html — 31 lois 2026 relevées. - conseil_constitutionnel : sans le paramètre ?id=32246 la page des affaires en instance ne rend que des QPC et aucune affaire DC. Les colonnes DC et QPC diffèrent : lecture par en-tête, jamais par position. Rapprochement à trois issues — numéro officiel, similarité de titre, et une zone de signalement où le pipeline s'abstient plutôt que de fusionner. make update-dry contre les sources réelles : aucun faux positif sur les 52 textes du corpus, et trois apports que le rapport n'avait pas — le numéro d'affaire 2026-907 DC de la programmation militaire, la date de saisine du 16 juillet pour l'aide à mourir, les décisions du 23 juillet sur 908 et 909. Une asymétrie de sources est documentée par un test plutôt que corrigée en douce : le Sénat ne qualifie pas la loi 2026-650 d'organique, Légifrance si. Le collecteur rapporte ce que sa source dit ; le champ n'est pas comparé. Fixtures HTML figées : aucun test ne touche au réseau. 156 tests, 91 % de couverture (chaque module au-dessus du seuil de 70 %). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""Rapprochement d'un texte collecté avec les textes déjà en base.
|
|
|
|
Deux voies, dans cet ordre :
|
|
|
|
1. **Le numéro officiel.** Deux textes portant le même numéro de loi sont le
|
|
même texte, sans discussion. C'est le cas facile, et le plus fréquent une
|
|
fois la promulgation intervenue.
|
|
2. **La similarité de titre**, pour les textes non encore promulgués, qui n'ont
|
|
pas de numéro. Le rapport les désigne par un nom d'usage (« loi Riposte »)
|
|
là où les sources officielles emploient l'intitulé complet (« Loi visant à
|
|
offrir des réponses immédiates aux phénomènes troublant l'ordre public… »).
|
|
La comparaison brute échouerait ; on compare donc des titres normalisés, et
|
|
on tient compte des mots significatifs communs.
|
|
|
|
Trois issues possibles, et c'est important : rapproché, **à signaler** (au-delà
|
|
du seuil bas mais en-deçà du seuil haut — un humain tranche), ou nouveau. Le
|
|
pipeline ne fusionne jamais sur une correspondance douteuse.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import re
|
|
from dataclasses import dataclass
|
|
from difflib import SequenceMatcher
|
|
from enum import StrEnum
|
|
|
|
import yaml
|
|
|
|
from pipeline import chemins
|
|
from pipeline.parseurs.dates_fr import sans_accents
|
|
|
|
# Mots vides et vocabulaire juridique passe-partout : deux lois quelconques les
|
|
# partagent, ils ne prouvent donc aucune parenté.
|
|
_MOTS_IGNORES = frozenset(
|
|
"""loi organique projet proposition relative relatif visant portant diverses
|
|
dispositions de des du la le les l a au aux et en pour par sur dans un une
|
|
ainsi que qui plus mesures texte article n no ndeg""".split()
|
|
)
|
|
|
|
_NON_ALPHANUM = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
class Issue(StrEnum):
|
|
RAPPROCHE = "rapproche"
|
|
A_SIGNALER = "a_signaler"
|
|
NOUVEAU = "nouveau"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Correspondance:
|
|
"""Résultat d'un rapprochement."""
|
|
|
|
issue: Issue
|
|
texte_id: str | None = None
|
|
score: float = 0.0
|
|
motif: str = ""
|
|
|
|
@property
|
|
def est_certaine(self) -> bool:
|
|
return self.issue is Issue.RAPPROCHE
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _seuils() -> tuple[float, float]:
|
|
configuration = yaml.safe_load(chemins.CONFIG.read_text(encoding="utf-8"))
|
|
rapprochement = configuration.get("rapprochement", {})
|
|
return (
|
|
float(rapprochement.get("seuil_similarite", 0.86)),
|
|
float(rapprochement.get("seuil_signalement", 0.72)),
|
|
)
|
|
|
|
|
|
def normaliser(titre: str) -> str:
|
|
"""Réduit un titre à ses mots significatifs, sans accent ni ponctuation."""
|
|
plat = sans_accents(titre).lower()
|
|
mots = [m for m in _NON_ALPHANUM.split(plat) if m and m not in _MOTS_IGNORES]
|
|
return " ".join(mots)
|
|
|
|
|
|
def similarite(gauche: str, droite: str) -> float:
|
|
"""Similarité de deux titres, entre 0 et 1.
|
|
|
|
Combine deux mesures : le recouvrement des mots significatifs, qui résiste
|
|
aux réordonnancements, et la similarité de séquence, qui capte les
|
|
variantes orthographiques. La plus favorable des deux l'emporte — un titre
|
|
court mais entièrement contenu dans un titre long doit être reconnu.
|
|
"""
|
|
a, b = normaliser(gauche), normaliser(droite)
|
|
if not a or not b:
|
|
return 0.0
|
|
if a == b:
|
|
return 1.0
|
|
|
|
mots_a, mots_b = set(a.split()), set(b.split())
|
|
communs = mots_a & mots_b
|
|
recouvrement = len(communs) / min(len(mots_a), len(mots_b))
|
|
sequence = SequenceMatcher(None, a, b).ratio()
|
|
|
|
return max(recouvrement, sequence)
|
|
|
|
|
|
def rapprocher(
|
|
*,
|
|
titre: str,
|
|
numero_officiel: str | None,
|
|
candidats: list[tuple[str, str, str | None]],
|
|
) -> Correspondance:
|
|
"""Rapproche un texte collecté des textes en base.
|
|
|
|
`candidats` est une liste de `(identifiant, titre, numero_officiel)`.
|
|
"""
|
|
seuil_haut, seuil_bas = _seuils()
|
|
|
|
if numero_officiel:
|
|
for identifiant, _, numero in candidats:
|
|
if numero and numero == numero_officiel:
|
|
return Correspondance(
|
|
issue=Issue.RAPPROCHE,
|
|
texte_id=identifiant,
|
|
score=1.0,
|
|
motif=f"numéro officiel identique ({numero_officiel})",
|
|
)
|
|
|
|
meilleur: tuple[float, str] | None = None
|
|
for identifiant, titre_candidat, _ in candidats:
|
|
score = similarite(titre, titre_candidat)
|
|
if meilleur is None or score > meilleur[0]:
|
|
meilleur = (score, identifiant)
|
|
|
|
if meilleur is None:
|
|
return Correspondance(issue=Issue.NOUVEAU, motif="aucun candidat en base")
|
|
|
|
score, identifiant = meilleur
|
|
if score >= seuil_haut:
|
|
return Correspondance(
|
|
issue=Issue.RAPPROCHE,
|
|
texte_id=identifiant,
|
|
score=score,
|
|
motif=f"similarité de titre {score:.2f} ≥ {seuil_haut}",
|
|
)
|
|
if score >= seuil_bas:
|
|
return Correspondance(
|
|
issue=Issue.A_SIGNALER,
|
|
texte_id=identifiant,
|
|
score=score,
|
|
motif=(
|
|
f"similarité de titre {score:.2f}, entre le seuil de signalement "
|
|
f"({seuil_bas}) et celui de rapprochement ({seuil_haut}) — "
|
|
"vérification humaine requise"
|
|
),
|
|
)
|
|
return Correspondance(
|
|
issue=Issue.NOUVEAU, score=score, motif=f"meilleure similarité {score:.2f}, sous les seuils"
|
|
)
|