Files
veye-lalwa/pipeline/rapprochement.py
T
Cyber MawonajandClaude Opus 5 feb5544599 Phase 5 : collecteurs officiels et run de veille — dry-run contre sources réelles
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>
2026-07-25 21:48:56 -04:00

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"
)