Files

199 lines
8.1 KiB
Python
Raw Permalink Normal View History

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