199 lines
8.1 KiB
Python
199 lines
8.1 KiB
Python
"""Arbitrages entre sources divergentes.
|
|||
|
|
|
||
|
|
Deux mécanismes distincts :
|
||
|
|
|
||
|
|
1. **Les cinq verdicts de la validation de phase 5** — des désaccords déjà
|
||
|
|
tranchés sur sources primaires, que le seed doit refléter. Le §7 du cahier
|
||
|
|
des charges en fait des critères d'acceptation : la loi « Riposte » doit
|
||
|
|
porter l'amende forfaitaire à **500 €** et non 1 500 € (montant abandonné
|
||
|
|
après la première lecture au Sénat), et la Guadeloupe ne doit **pas**
|
||
|
|
figurer parmi les territoires concernés par les sénatoriales de 2026.
|
||
|
|
|
||
|
|
2. **La hiérarchie des sources** pour le pipeline de mise à jour : Journal
|
||
|
|
officiel et Légifrance priment sur l'Assemblée et le Sénat, qui priment sur
|
||
|
|
le Conseil constitutionnel, qui prime sur la presse. Déclarée dans
|
||
|
|
`pipeline/config.yaml`, appliquée par `update.py`.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import functools
|
||
|
|
import re
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
from pipeline import chemins
|
||
|
|
from pipeline.journal import logger
|
||
|
|
from pipeline.parseurs.markdown import extraire_tableaux, nettoyer
|
||
|
|
|
||
|
|
log = logger("conflits")
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# 1. Verdicts de la validation de phase 5
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
@dataclass(slots=True)
|
||
|
|
class Verdict:
|
||
|
|
"""Un désaccord tranché sur source primaire."""
|
||
|
|
|
||
|
|
numero: int
|
||
|
|
objet: str
|
||
|
|
statut: str # « TRANCHÉ »
|
||
|
|
reponse: str
|
||
|
|
textes_concernes: list[str] = field(default_factory=list)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def point_cle(self) -> str:
|
||
|
|
return f"Arbitrage (validation du 25 juillet 2026) : {self.reponse}"
|
||
|
|
|
||
|
|
|
||
|
|
# Rattachement de chaque verdict aux textes qu'il concerne. La table est
|
||
|
|
# déclarative et non devinée : chaque ligne cite l'objet du désaccord tel qu'il
|
||
|
|
# figure dans `lois-2026_phase5_validation.md`.
|
||
|
|
TEXTES_PAR_VERDICT: dict[int, list[str]] = {
|
||
|
|
# « AFD "participation" free party : 500 € (A) ou 1 500 € (B) ? »
|
||
|
|
1: ["loi-riposte"],
|
||
|
|
# « AFD "inhalation" N₂O : 200 € ou 500 € ? »
|
||
|
|
2: ["loi-riposte"],
|
||
|
|
# « Saisine CC réellement déposée au 25/07 ? Loi promulguée ? »
|
||
|
|
3: ["loi-riposte"],
|
||
|
|
# « Guadeloupe concernée par les sénatoriales du 27/09/2026 ? »
|
||
|
|
# Aucun texte de la base : l'arbitrage porte sur une échéance du calendrier.
|
||
|
|
4: [],
|
||
|
|
# « Autres lois promulguées entre le 21 et le 25/07/2026 ? »
|
||
|
|
# Porte sur l'exhaustivité de l'inventaire, pas sur un texte en particulier.
|
||
|
|
5: [],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def lire_verdicts(markdown: str) -> list[Verdict]:
|
||
|
|
"""Lit la synthèse des verdicts de la validation de phase 5."""
|
||
|
|
verdicts: list[Verdict] = []
|
||
|
|
|
||
|
|
for tableau in extraire_tableaux(markdown):
|
||
|
|
if not tableau.entetes or tableau.entetes[0] != "#":
|
||
|
|
continue
|
||
|
|
for ligne in tableau.lignes:
|
||
|
|
numero_brut = nettoyer(ligne["#"])
|
||
|
|
if not numero_brut.isdigit():
|
||
|
|
continue
|
||
|
|
numero = int(numero_brut)
|
||
|
|
verdicts.append(
|
||
|
|
Verdict(
|
||
|
|
numero=numero,
|
||
|
|
objet=nettoyer(ligne.get("Objet du désaccord", "")),
|
||
|
|
statut=nettoyer(ligne.get("Verdict", "")),
|
||
|
|
reponse=nettoyer(ligne.get("Réponse tranchée", "")),
|
||
|
|
textes_concernes=TEXTES_PAR_VERDICT.get(numero, []),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
log.info("verdicts de validation lus", verdicts=len(verdicts))
|
||
|
|
return verdicts
|
||
|
|
|
||
|
|
|
||
|
|
# Précisions chiffrées à porter en points clés, reprises verbatim des claims
|
||
|
|
# décisifs de la validation. Elles répondent au §7 du cahier des charges, qui
|
||
|
|
# exige de retrouver « AFD 500 € » sur la loi « Riposte ».
|
||
|
|
PRECISIONS_RIPOSTE: tuple[str, ...] = (
|
||
|
|
"Participation à une free party non autorisée : 6 mois d'emprisonnement et "
|
||
|
|
"7 500 € d'amende ; amende forfaitaire délictuelle (AFD) de 500 € "
|
||
|
|
"(minorée 400 €, majorée 1 000 €) — art. L. 211-15-4 du code de la "
|
||
|
|
"sécurité intérieure. Le montant de 1 500 € était la version votée par le "
|
||
|
|
"Sénat en première lecture, abandonnée.",
|
||
|
|
"Inhalation de protoxyde d'azote : 1 an d'emprisonnement et 3 750 € "
|
||
|
|
"d'amende ; AFD de 500 € — art. L. 3611-4 du code de la santé publique.",
|
||
|
|
"Autres amendes forfaitaires délictuelles du texte : détention et transport "
|
||
|
|
"500 €, vente illicite 800 €.",
|
||
|
|
)
|
||
|
|
|
||
|
|
PRECISIONS_PAR_TEXTE: dict[str, tuple[str, ...]] = {
|
||
|
|
"loi-riposte": PRECISIONS_RIPOSTE,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def appliquer(textes: dict, verdicts: list[Verdict]) -> int:
|
||
|
|
"""Porte les arbitrages dans les points clés des textes concernés.
|
||
|
|
|
||
|
|
Retourne le nombre de textes modifiés.
|
||
|
|
"""
|
||
|
|
modifies = 0
|
||
|
|
|
||
|
|
for verdict in verdicts:
|
||
|
|
for texte_id in verdict.textes_concernes:
|
||
|
|
texte = textes.get(texte_id)
|
||
|
|
if texte is None:
|
||
|
|
log.warning("verdict orphelin", verdict=verdict.numero, texte=texte_id)
|
||
|
|
continue
|
||
|
|
if verdict.point_cle not in texte.points_cles:
|
||
|
|
texte.points_cles.append(verdict.point_cle)
|
||
|
|
modifies += 1
|
||
|
|
|
||
|
|
for texte_id, precisions in PRECISIONS_PAR_TEXTE.items():
|
||
|
|
texte = textes.get(texte_id)
|
||
|
|
if texte is None:
|
||
|
|
continue
|
||
|
|
for precision in precisions:
|
||
|
|
if precision not in texte.points_cles:
|
||
|
|
texte.points_cles.append(precision)
|
||
|
|
|
||
|
|
return modifies
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# 2. Hiérarchie des sources pour la mise à jour
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
@functools.lru_cache(maxsize=1)
|
||
|
|
def _hierarchie() -> list[str]:
|
||
|
|
configuration = yaml.safe_load(chemins.CONFIG.read_text(encoding="utf-8"))
|
||
|
|
return list(configuration.get("hierarchie_sources", []))
|
||
|
|
|
||
|
|
|
||
|
|
def rang_de_source(url: str) -> int:
|
||
|
|
"""Rang d'autorité d'une URL : plus il est bas, plus la source prime.
|
||
|
|
|
||
|
|
Les hôtes inconnus — presse et analyses — reçoivent le rang le plus faible.
|
||
|
|
"""
|
||
|
|
hote = (urlparse(url).hostname or "").lower().removeprefix("www.")
|
||
|
|
for rang, reference in enumerate(_hierarchie()):
|
||
|
|
if hote == reference or hote.endswith("." + reference):
|
||
|
|
return rang
|
||
|
|
return len(_hierarchie())
|
||
|
|
|
||
|
|
|
||
|
|
def trancher(candidats: list[tuple[str, str]]) -> str | None:
|
||
|
|
"""Choisit une valeur parmi des couples (valeur, url) divergents.
|
||
|
|
|
||
|
|
La valeur retenue est celle qu'affirme la source la plus autorisée. À
|
||
|
|
autorité égale, la valeur majoritaire l'emporte ; à égalité parfaite, aucune
|
||
|
|
n'est retenue — le pipeline signale plutôt que de trancher au hasard.
|
||
|
|
"""
|
||
|
|
if not candidats:
|
||
|
|
return None
|
||
|
|
|
||
|
|
par_valeur: dict[str, tuple[int, int]] = {}
|
||
|
|
for valeur, url in candidats:
|
||
|
|
rang = rang_de_source(url)
|
||
|
|
meilleur_rang, occurrences = par_valeur.get(valeur, (len(_hierarchie()) + 1, 0))
|
||
|
|
par_valeur[valeur] = (min(meilleur_rang, rang), occurrences + 1)
|
||
|
|
|
||
|
|
classement = sorted(par_valeur.items(), key=lambda item: (item[1][0], -item[1][1]))
|
||
|
|
if len(classement) > 1 and classement[0][1] == classement[1][1]:
|
||
|
|
return None
|
||
|
|
return classement[0][0]
|
||
|
|
|
||
|
|
|
||
|
|
_MONTANT = re.compile(r"(\d[\d\s ]*)\s*(?:€|euros)", re.IGNORECASE)
|
||
|
|
|
||
|
|
|
||
|
|
def montants_cites(fragment: str) -> list[int]:
|
||
|
|
"""Montants en euros cités dans un fragment, pour les contrôles d'acceptation."""
|
||
|
|
montants: list[int] = []
|
||
|
|
for brut in _MONTANT.findall(fragment):
|
||
|
|
chiffres = re.sub(r"\D", "", brut)
|
||
|
|
if chiffres:
|
||
|
|
montants.append(int(chiffres))
|
||
|
|
return montants
|