Phase 4 : impacts par public, arbitrages, calendrier — les 7 critères §7 passent

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>
This commit is contained in:
Cyber Mawonaj
2026-07-25 20:23:51 -04:00
co-authored by Claude Opus 5
parent c28243cf87
commit f3e546cfc4
8 changed files with 922 additions and 3 deletions
+198
View File
@@ -0,0 +1,198 @@
"""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