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