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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9fc8f3f67
commit
feb5544599
@@ -0,0 +1,155 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user