Impacts : le rapport ne dresse pas de tableau texte × public, mais il consacre un chapitre à chacun (sec04 individus, sec05 associations, sec06 entreprises). C'est ce placement éditorial — un choix explicite de l'auteur, pas une inférence — qui sert de base à la ventilation. Le sens vient de la cotation manuelle de l'annexe quand elle existe, sinon du vocabulaire, et le texte est alors marqué « à vérifier » (17 cas). Arbitrages de la validation de phase 5 portés en points clés : loi-riposte affiche l'AFD à 500 €, et le montant de 1 500 € n'apparaît que présenté comme la version sénatoriale abandonnée. Migration 004 + calendrier : 9 échéances nationales transcrites du corpus avec leur extrait source. Celle des sénatoriales porte explicitement concerne_guadeloupe = 0 — série 1, renouvelée en 2023. Défaut de qualité corrigé : un titre de section nommant plusieurs textes (« Cohésion républicaine, logement, GloBE, entrisme ») faisait hériter chaque phrase de transition aux quatre, polluant leurs résumés. Une section n'est dédiée que si elle nomme un seul texte. 118 tests, 92 % de couverture. Les 7 garde-fous du §7 et les 5 démos du §8.7 sont automatisés dans tests/test_acceptation.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|