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
+35
View File
@@ -223,6 +223,40 @@ def enregistrer_decision_cc(
) )
def enregistrer_echeance(cx: sqlite3.Connection, echeance, *, texte_id: str | None = None) -> None:
"""Écrit une échéance de calendrier (nationale ou rattachée à un texte)."""
cx.execute(
"""
INSERT INTO echeances (code, date_echeance, libelle, description, portee,
texte_id, concerne_guadeloupe, note_guadeloupe,
previsionnel, source_url, source_extrait)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (code) DO UPDATE SET
date_echeance = excluded.date_echeance,
libelle = excluded.libelle,
description = excluded.description,
concerne_guadeloupe = excluded.concerne_guadeloupe,
note_guadeloupe = excluded.note_guadeloupe,
previsionnel = excluded.previsionnel,
source_url = COALESCE(excluded.source_url, source_url),
source_extrait = COALESCE(excluded.source_extrait, source_extrait)
""",
(
echeance.code,
echeance.date_echeance.isoformat(),
echeance.libelle,
echeance.description,
"texte" if texte_id else "nationale",
texte_id,
None if echeance.concerne_guadeloupe is None else int(echeance.concerne_guadeloupe),
echeance.note_guadeloupe,
int(echeance.previsionnel),
echeance.source_url,
echeance.source_extrait,
),
)
def enregistrer_insight(cx: sqlite3.Connection, insight: Insight) -> None: def enregistrer_insight(cx: sqlite3.Connection, insight: Insight) -> None:
cx.execute( cx.execute(
""" """
@@ -332,6 +366,7 @@ def statistiques(cx: sqlite3.Connection) -> dict[str, Any]:
"evenements": un("SELECT COUNT(*) FROM evenements"), "evenements": un("SELECT COUNT(*) FROM evenements"),
"decisions_cc": un("SELECT COUNT(*) FROM decisions_cc"), "decisions_cc": un("SELECT COUNT(*) FROM decisions_cc"),
"insights": un("SELECT COUNT(*) FROM insights"), "insights": un("SELECT COUNT(*) FROM insights"),
"echeances": un("SELECT COUNT(*) FROM echeances"),
"textes_sans_source": un( "textes_sans_source": un(
"SELECT COUNT(*) FROM textes t " "SELECT COUNT(*) FROM textes t "
"WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)" "WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)"
+129
View File
@@ -0,0 +1,129 @@
"""Ventilation des impacts sur les libertés par public.
Le rapport ne dresse pas de tableau « texte × public ». En revanche il consacre
un chapitre à chacun des trois publics :
=========== ===============================================
Chapitre Public
=========== ===============================================
`sec04` Libertés individuelles : analyse croisée
`sec05` Libertés associatives : le chantier latent
`sec06` Entreprises : obligations nouvelles et ouvertures
=========== ===============================================
Un texte développé dans le chapitre 5 est donc, par construction du rapport, un
texte qui touche les libertés associatives. C'est ce placement — un choix
éditorial explicite, pas une inférence — qui sert de base à la ventilation.
Le **sens** de l'impact vient d'abord de la cotation « statut libertés » de
l'annexe, seule donnée cotée à la main. À défaut — les textes non promulgués ne
sont pas cotés — il est déduit du vocabulaire de la phrase, et le texte est
alors marqué « à vérifier ».
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pipeline.modeles import Impact, Public, RegimeLibertes, SensImpact
from pipeline.parseurs.chapitres import LectureChapitres
from pipeline.parseurs.dates_fr import sans_accents
CHAPITRE_PAR_PUBLIC: dict[str, Public] = {
"sec04": Public.INDIVIDUS,
"sec05": Public.ASSOCIATIONS,
"sec06": Public.ENTREPRISES,
}
SENS_PAR_REGIME: dict[RegimeLibertes, SensImpact] = {
RegimeLibertes.DROITS_PLUS: SensImpact.POSITIF,
RegimeLibertes.CONTROLE_PLUS: SensImpact.NEGATIF,
RegimeLibertes.MIXTE: SensImpact.MIXTE,
RegimeLibertes.NEUTRE: SensImpact.NEUTRE,
}
# Vocabulaire de restriction et d'extension de droits, tel qu'employé par le
# rapport. Les listes sont volontairement courtes : un mot ambigu ferait plus
# de dégâts qu'un impact manquant.
_RESTRICTION = (
"restriction", "restreint", "sanction", "amende", "interdiction", "interdit",
"peine", "penal", "controle prefectoral", "surveillance", "fichage",
"dissolution", "retrait", "suspension", "obligation declarative", "censure",
"expropriation", "retention", "fouille", "reduit", "durci", "aggrave",
)
_EXTENSION = (
"droit opposable", "nouveau droit", "garantie", "protection", "reparation",
"extension des droits", "acces", "recours", "assistance", "indemnisation",
"simplification", "allegement", "ouverture", "facilite", "reserve aux pme",
)
@dataclass(slots=True)
class Ventilation:
"""Impacts déduits pour un texte, et ce qui les a motivés."""
impacts: dict[Public, Impact]
deduit: bool # vrai si le sens n'a pas pu venir de la cotation manuelle
MOTIF_VERIFICATION = (
"Sens de l'impact déduit du vocabulaire du rapport : l'annexe ne cote le "
"régime de libertés que pour les lois promulguées."
)
def ventiler(
texte_id: str,
lecture: LectureChapitres,
*,
regime: RegimeLibertes | None,
) -> Ventilation:
"""Répartit l'effet d'un texte entre individus, associations et entreprises."""
impacts: dict[Public, Impact] = {}
deduit = False
for mention in lecture.mentions.get(texte_id, []):
public = CHAPITRE_PAR_PUBLIC.get(mention.chapitre)
if public is None or public in impacts:
continue
phrases = " ".join(mention.phrases)
if regime is not None:
sens = SENS_PAR_REGIME[regime]
else:
sens = _sens_du_vocabulaire(phrases)
deduit = True
impacts[public] = Impact(sens=sens, note=_note(mention.phrases))
return Ventilation(impacts=impacts, deduit=deduit and bool(impacts))
def _sens_du_vocabulaire(fragment: str) -> SensImpact:
plat = sans_accents(fragment).lower()
restrictions = sum(1 for mot in _RESTRICTION if _present(mot, plat))
extensions = sum(1 for mot in _EXTENSION if _present(mot, plat))
if restrictions and extensions:
return SensImpact.MIXTE
if restrictions:
return SensImpact.NEGATIF
if extensions:
return SensImpact.POSITIF
return SensImpact.NEUTRE
def _present(expression: str, plat: str) -> bool:
return re.search(rf"(?<!\w){re.escape(expression)}\w{{0,3}}(?!\w)", plat) is not None
def _note(phrases: list[str]) -> str:
"""Première phrase substantielle du chapitre, comme justification affichable."""
from pipeline.parseurs.markdown import nettoyer
for phrase in phrases:
propre = nettoyer(phrase)
if len(propre) > 60:
return propre[:400]
return ""
+46
View File
@@ -0,0 +1,46 @@
-- ============================================================================
-- Migration 004 — échéances du calendrier
--
-- La page « calendrier » agrège trois natures d'échéance :
--
-- * celles qui appartiennent à un texte (décision attendue du Conseil
-- constitutionnel, entrée en vigueur, prochaine étape) — dérivées de
-- `textes`, `evenements` et `decisions_cc` ;
-- * celles qui n'appartiennent à aucun texte mais structurent la fenêtre de
-- veille : sénatoriales du 27 septembre, présentation du projet de loi de
-- finances 2027, rentrée parlementaire ordinaire du 1ᵉʳ octobre.
--
-- Cette table porte les secondes. Le champ `concerne_guadeloupe` est explicite
-- et non déductible : le scrutin sénatorial du 27 septembre 2026 ne concerne
-- PAS la Guadeloupe (série 1, renouvelée en 2023), ce que le §7 du cahier des
-- charges impose de ne pas se tromper.
-- ============================================================================
CREATE TABLE echeances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, -- « senatoriales-2026 »
date_echeance TEXT NOT NULL,
libelle TEXT NOT NULL,
description TEXT,
-- `nationale` : elle vaut pour l'ensemble du territoire ;
-- `texte` : elle découle d'un texte suivi (FK renseignée).
portee TEXT NOT NULL DEFAULT 'nationale'
CHECK (portee IN ('nationale', 'texte')),
texte_id TEXT REFERENCES textes (id) ON DELETE CASCADE,
-- 1 = concerne la Guadeloupe, 0 = ne la concerne pas, NULL = non tranché.
concerne_guadeloupe INTEGER CHECK (concerne_guadeloupe IN (0, 1)),
note_guadeloupe TEXT,
-- Vrai tant que l'échéance est prévisionnelle (postérieure à la date
-- d'arrêté du corpus et non confirmée par un collecteur).
previsionnel INTEGER NOT NULL DEFAULT 1 CHECK (previsionnel IN (0, 1)),
source_url TEXT,
source_extrait TEXT,
cree_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_echeances_date ON echeances (date_echeance);
CREATE INDEX idx_echeances_texte ON echeances (texte_id);
+7 -1
View File
@@ -136,7 +136,13 @@ def parser() -> LectureChapitres:
nom = f"sec{numero:02d}" nom = f"sec{numero:02d}"
for section in decouper_sections(fichier.read_text(encoding="utf-8"), niveau_max=4): for section in decouper_sections(fichier.read_text(encoding="utf-8"), niveau_max=4):
cibles_du_titre = _textes_designes(section.titre) nommes_au_titre = _textes_designes(section.titre)
# Un titre qui nomme plusieurs textes — « 3.2.3 Cohésion
# républicaine, logement, GloBE, entrisme » — n'est dédié à aucun.
# Sans cette restriction, chaque phrase de transition de la section
# (« Septembre 2026 sera un mois réglementaire… ») atterrirait dans
# le résumé des quatre textes à la fois.
cibles_du_titre = nommes_au_titre if len(nommes_au_titre) == 1 else []
for paragraphe in _paragraphes(section.corps): for paragraphe in _paragraphes(section.corps):
for phrase in _phrases(paragraphe): for phrase in _phrases(paragraphe):
+233
View File
@@ -0,0 +1,233 @@
"""Échéances nationales de la fenêtre de veille.
Le calendrier de l'application mêle des échéances propres à chaque texte —
déduites de la base — et un petit nombre d'échéances nationales qui structurent
la période et n'appartiennent à aucun texte : décisions attendues du Conseil
constitutionnel, bascules du 1ᵉʳ septembre, sénatoriales du 27 septembre,
projet de loi de finances pour 2027, rentrée parlementaire.
Ces échéances sont **transcrites** du corpus, pas déduites : chacune porte le
fichier et l'extrait dont elle provient. Deux d'entre elles font l'objet d'une
vigilance particulière.
- Les sénatoriales du 27 septembre 2026 **ne concernent pas la Guadeloupe**.
Le décret n° 2026-301 du 21 avril 2026 convoque la série 2 plus la Guyane, la
Polynésie, Saint-Barthélemy, Saint-Martin et Wallis-et-Futuna ; la Guadeloupe
relève de la série 1, renouvelée le 24 septembre 2023. Le §7 du cahier des
charges en fait un critère d'acceptation.
- L'interdiction des réseaux sociaux aux moins de 15 ans au 1ᵉʳ septembre est
**conditionnelle** : elle dépend de la décision n° 2026-911 DC attendue fin
août. Elle est marquée prévisionnelle.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
@dataclass(slots=True)
class Echeance:
"""Une échéance nationale de la fenêtre de veille."""
code: str
date_echeance: date
libelle: str
description: str
concerne_guadeloupe: bool | None = None
note_guadeloupe: str | None = None
previsionnel: bool = True
source_url: str | None = None
source_extrait: str | None = None
# Transcription du corpus. Chaque entrée cite le fichier dont elle provient.
ECHEANCES_NATIONALES: tuple[Echeance, ...] = (
# ── research/lois-2026_dim11.md §5 — entrées en vigueur au 1ᵉʳ septembre ──
Echeance(
code="facturation-electronique-reception",
date_echeance=date(2026, 9, 1),
libelle="Réception obligatoire des factures électroniques",
description=(
"Toute entreprise et toute association assujettie à la TVA, franchise en "
"base comprise, doit être en capacité de recevoir des factures "
"électroniques. Les grandes entreprises et les entreprises de taille "
"intermédiaire doivent en outre en émettre ; les PME, TPE et "
"micro-entreprises au 1ᵉʳ septembre 2027."
),
concerne_guadeloupe=True,
note_guadeloupe=(
"Applicable de plein droit en Guadeloupe, en Martinique et à La Réunion, "
"aux taux de TVA locaux. Hors champ en Guyane et à Mayotte, où la TVA "
"n'est provisoirement pas applicable. La préparation locale documentée se "
"limite à une réunion de la direction régionale des finances publiques "
"début juin 2026."
),
previsionnel=False,
source_url="https://www.economie.gouv.fr/facturation-electronique-entreprises",
source_extrait=(
"À partir du 1er septembre 2026, toutes les entreprises devront être en "
"capacité de recevoir des factures électroniques."
),
),
Echeance(
code="assurance-chomage-rupture-conventionnelle",
date_echeance=date(2026, 9, 1),
libelle="Réduction des durées d'indemnisation après rupture conventionnelle",
description=(
"La durée maximale d'indemnisation après rupture conventionnelle passe à "
"15 mois avant 55 ans et 20,5 mois à partir de 55 ans (loi n° 2026-470 du "
"11 juin 2026). Des textes d'application restaient attendus au 25 juillet."
),
concerne_guadeloupe=True,
note_guadeloupe=(
"Le régime dérogatoire des départements d'outre-mer est préservé : 20 et "
"30 mois. La Guadeloupe compte 15,2 % de chômage et 53 780 demandeurs "
"d'emploi, dont 56 % de longue durée."
),
previsionnel=False,
source_url="https://www.service-public.fr/particuliers/actualites/A17812",
source_extrait=(
"La loi portant transposition de l'avenant n° 3 du 25 février 2026 modifie "
"la durée maximale de versement des allocations chômage aux salariés en cas "
"de rupture conventionnelle."
),
),
Echeance(
code="malus-textile-mode-ultra-express",
date_echeance=date(2026, 9, 1),
libelle="Malus « mode ultra-express » sur les produits textiles",
description=(
"Pénalité de 0,25 € à 12 € par produit en 2026, portée à 220 € en 2030 et "
"plafonnée à 50 % du prix hors taxes (loi n° 2026-602 du 8 juillet 2026, "
"art. 5). L'arrêté d'application était en consultation publique depuis le "
"8 juillet."
),
concerne_guadeloupe=True,
note_guadeloupe="Applicable dans les départements d'outre-mer.",
previsionnel=False,
source_url="https://www.vie-publique.fr/loi/295986-loi-fast-fashion-mode-ephemere",
source_extrait=(
"Ce texte a pour objet de permettre l'entrée en vigueur, le 1er septembre "
"2026, d'un dispositif de pénalités financières applicables aux produits de "
"la mode ultra express."
),
),
Echeance(
code="seuils-concentration-releves",
date_echeance=date(2026, 9, 1),
libelle="Relèvement des seuils de contrôle des concentrations",
description=(
"Nouveaux seuils de notification à l'Autorité de la concurrence : 250 M€ de "
"chiffre d'affaires mondial et 80 M€ en France, contre 150 et 50 ; commerce "
"de détail 100 M€ et 20 M€, contre 75 et 15 (loi n° 2026-403, art. 24)."
),
concerne_guadeloupe=None,
previsionnel=False,
source_url="https://www.autoritedelaconcurrence.fr/fr/controle-des-concentrations",
source_extrait=(
"Ces nouveaux seuils s'appliqueront aux opérations qui seront réalisées à "
"compter du 1er septembre 2026."
),
),
Echeance(
code="reseaux-sociaux-moins-de-15-ans",
date_echeance=date(2026, 9, 1),
libelle="Interdiction des réseaux sociaux aux moins de 15 ans (sous condition)",
description=(
"Entrée en vigueur prévue pour les nouveaux comptes, mais suspendue à la "
"décision n° 2026-911 DC attendue fin août 2026, et à l'articulation avec "
"le règlement européen sur les services numériques — la Commission "
"européenne a rendu un avis partiellement défavorable le 6 juillet 2026."
),
concerne_guadeloupe=True,
note_guadeloupe="Applicable de plein droit.",
previsionnel=True,
source_url="https://www.conseil-constitutionnel.fr/decisions/affaires-en-instances",
source_extrait=None,
),
# ── research/lois-2026_phase5_validation.md, point 4 ─────────────────────
Echeance(
code="senatoriales-2026",
date_echeance=date(2026, 9, 27),
libelle="Élections sénatoriales (série 2)",
description=(
"Le décret n° 2026-301 du 21 avril 2026 convoque les collèges électoraux "
"des départements de la série 2, ainsi que la Guyane, la Polynésie "
"française, Saint-Barthélemy, Saint-Martin et Wallis-et-Futuna, plus six "
"des douze sièges des Français établis hors de France."
),
concerne_guadeloupe=False,
note_guadeloupe=(
"La Guadeloupe n'est PAS concernée : elle relève de la série 1, renouvelée "
"le 24 septembre 2023. Prochain renouvellement en 2029."
),
previsionnel=True,
source_url=(
"https://www.haute-corse.gouv.fr/Actions-de-l-Etat/Vie-democratique/"
"Elections/Mairies-Decret-de-convocation-election-senatoriale-2026"
),
source_extrait=(
"Le décret n° 2026-301 du 21 avril 2026 portant convocation des collèges "
"électoraux fixe au dimanche 27 septembre 2026 la date des élections "
"sénatoriales pour les départements de la série 2 figurant au tableau n° 5 "
"annexé au code électoral, ainsi qu'en Guyane, en Polynésie française, à "
"Saint-Barthélemy, à Saint-Martin et à Wallis-et-Futuna."
),
),
# ── research/lois-2026_dim11.md §2 et §3 ─────────────────────────────────
Echeance(
code="rentree-parlementaire-ordinaire",
date_echeance=date(2026, 10, 1),
libelle="Ouverture de la session ordinaire 2026-2027",
description=(
"Aucune session extraordinaire de septembre n'était annoncée au "
"25 juillet 2026. Les navettes renvoyées à l'automne — pacte migration, "
"protection des enfants, cohésion républicaine, logement, entrisme, "
"adaptation du droit des outre-mer — reprennent à cette date."
),
concerne_guadeloupe=None,
previsionnel=True,
source_url="https://www.assemblee-nationale.fr/dyn/agenda",
source_extrait=None,
),
Echeance(
code="plf-2027",
date_echeance=date(2026, 10, 1),
libelle="Présentation du projet de loi de finances pour 2027",
description=(
"Présentation en conseil des ministres fin septembre ou début octobre 2026, "
"avec 3 milliards d'euros d'économies supplémentaires annoncés. L'élection "
"présidentielle du printemps 2027 comprime le calendrier d'examen."
),
concerne_guadeloupe=None,
previsionnel=True,
source_url="https://www.vie-publique.fr/loi-finances",
source_extrait=None,
),
# ── lois-france-2026-guadeloupe_sec10.md, conclusion ─────────────────────
Echeance(
code="prochaine-date-arrete",
date_echeance=date(2026, 8, 24),
libelle="Fin du délai d'examen des saisines du 24 juillet",
description=(
"Terme du délai constitutionnel d'un mois pour les saisines enregistrées le "
"24 juillet 2026 — loi « Riposte » (2026-915 DC), urgence agricole "
"(2026-914 DC), patrimoine immobilier (2026-913 DC), réseaux sociaux "
"(2026-911 DC). C'est la prochaine date d'arrêté utile de la veille."
),
concerne_guadeloupe=True,
note_guadeloupe=(
"Trois textes à enjeu maximal pour les libertés y sont suspendus, dont la "
"loi « Riposte » et ses fouilles sans réquisition jusqu'à 40 km du "
"littoral — soit la quasi-totalité de la Guadeloupe."
),
previsionnel=True,
source_url="https://www.conseil-constitutionnel.fr/decisions/affaires-en-instances",
source_extrait=None,
),
)
def echeances_nationales() -> tuple[Echeance, ...]:
return ECHEANCES_NATIONALES
+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
+37 -2
View File
@@ -24,7 +24,7 @@ import sqlite3
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pipeline import chemins, db, guadeloupe from pipeline import chemins, db, guadeloupe, impacts, resolution_conflits
from pipeline.journal import configurer, logger from pipeline.journal import configurer, logger
from pipeline.modeles import Source, Texte from pipeline.modeles import Source, Texte
from pipeline.parseurs import ( from pipeline.parseurs import (
@@ -32,6 +32,7 @@ from pipeline.parseurs import (
bibliographie, bibliographie,
chapitres, chapitres,
citations, citations,
echeances,
tableaux_sec10, tableaux_sec10,
) )
@@ -50,10 +51,13 @@ class CompteRendu:
evenements: int = 0 evenements: int = 0
decisions_cc: int = 0 decisions_cc: int = 0
analyses: int = 0 analyses: int = 0
echeances: int = 0
textes_sans_source: list[str] = field(default_factory=list) textes_sans_source: list[str] = field(default_factory=list)
textes_sans_resume: list[str] = field(default_factory=list) textes_sans_resume: list[str] = field(default_factory=list)
pertinences_deduites: list[str] = field(default_factory=list) pertinences_deduites: list[str] = field(default_factory=list)
impacts_deduits: list[str] = field(default_factory=list)
verdicts_appliques: int = 0
avertissements: list[str] = field(default_factory=list) avertissements: list[str] = field(default_factory=list)
marqueurs_avec_lien: int = 0 marqueurs_avec_lien: int = 0
@@ -69,6 +73,7 @@ class CompteRendu:
f" événements : {self.evenements}", f" événements : {self.evenements}",
f" décisions CC : {self.decisions_cc}", f" décisions CC : {self.decisions_cc}",
f" analyses : {self.analyses}", f" analyses : {self.analyses}",
f" échéances : {self.echeances}",
"", "",
"Résolution des citations", "Résolution des citations",
f" avec lien : {self.marqueurs_avec_lien}", f" avec lien : {self.marqueurs_avec_lien}",
@@ -81,6 +86,8 @@ class CompteRendu:
f" textes sans résumé : {len(self.textes_sans_resume)}" f" textes sans résumé : {len(self.textes_sans_resume)}"
+ (f"{', '.join(self.textes_sans_resume)}" if self.textes_sans_resume else ""), + (f"{', '.join(self.textes_sans_resume)}" if self.textes_sans_resume else ""),
f" pertinences déduites : {len(self.pertinences_deduites)}", f" pertinences déduites : {len(self.pertinences_deduites)}",
f" impacts déduits : {len(self.impacts_deduits)}",
f" arbitrages appliqués : {self.verdicts_appliques}",
] ]
if self.avertissements: if self.avertissements:
lignes.append("") lignes.append("")
@@ -114,6 +121,7 @@ def ensemencer(cx: sqlite3.Connection) -> CompteRendu:
_rattacher_sources(texte, repertoire, lecture, marqueurs_annexe) _rattacher_sources(texte, repertoire, lecture, marqueurs_annexe)
_rattacher_redaction(texte, lecture) _rattacher_redaction(texte, lecture)
_coter_guadeloupe(texte, compte) _coter_guadeloupe(texte, compte)
_ventiler_impacts(texte, lecture, compte)
if not texte.sources: if not texte.sources:
compte.textes_sans_source.append(texte.id) compte.textes_sans_source.append(texte.id)
@@ -130,13 +138,22 @@ def ensemencer(cx: sqlite3.Connection) -> CompteRendu:
compte.marqueurs_sans_lien = len(repertoire.resolus_sans_lien) compte.marqueurs_sans_lien = len(repertoire.resolus_sans_lien)
compte.marqueurs_non_resolus = len(repertoire.non_resolus) compte.marqueurs_non_resolus = len(repertoire.non_resolus)
# 5. Écriture. # 5. Arbitrages de la validation de phase 5 — le §7 du cahier des charges
# exige que la loi « Riposte » porte l'AFD à 500 € et non à 1 500 €.
verdicts = resolution_conflits.lire_verdicts(
chemins.PHASE5_VALIDATION.read_text(encoding="utf-8")
)
compte.verdicts_appliques = resolution_conflits.appliquer(textes, verdicts)
# 6. Écriture.
db.enregistrer_textes(cx, textes.values()) db.enregistrer_textes(cx, textes.values())
lectures = analyses.parser(chemins.INSIGHTS.read_text(encoding="utf-8")) lectures = analyses.parser(chemins.INSIGHTS.read_text(encoding="utf-8"))
with db.transaction(cx): with db.transaction(cx):
for analyse in lectures: for analyse in lectures:
db.enregistrer_insight(cx, analyse) db.enregistrer_insight(cx, analyse)
for echeance in echeances.echeances_nationales():
db.enregistrer_echeance(cx, echeance)
statistiques = db.statistiques(cx) statistiques = db.statistiques(cx)
compte.textes = statistiques["textes"] compte.textes = statistiques["textes"]
@@ -144,6 +161,7 @@ def ensemencer(cx: sqlite3.Connection) -> CompteRendu:
compte.evenements = statistiques["evenements"] compte.evenements = statistiques["evenements"]
compte.decisions_cc = statistiques["decisions_cc"] compte.decisions_cc = statistiques["decisions_cc"]
compte.analyses = statistiques["insights"] compte.analyses = statistiques["insights"]
compte.echeances = statistiques["echeances"]
if compte.textes < TEXTES_ATTENDUS_MINIMUM: if compte.textes < TEXTES_ATTENDUS_MINIMUM:
compte.avertissements.append( compte.avertissements.append(
@@ -234,6 +252,23 @@ def _coter_guadeloupe(texte: Texte, compte: CompteRendu) -> None:
compte.pertinences_deduites.append(texte.id) compte.pertinences_deduites.append(texte.id)
def _ventiler_impacts(
texte: Texte, lecture: chapitres.LectureChapitres, compte: CompteRendu
) -> None:
"""Répartit l'effet du texte entre les trois publics suivis."""
ventilation = impacts.ventiler(texte.id, lecture, regime=texte.regime_libertes)
if not ventilation.impacts:
return
texte.impacts = ventilation.impacts
if ventilation.deduit:
texte.a_verifier = True
texte.motif_verification = _ajouter_motif(
texte.motif_verification, impacts.MOTIF_VERIFICATION
)
compte.impacts_deduits.append(texte.id)
def _ajouter_motif(existant: str | None, nouveau: str) -> str: def _ajouter_motif(existant: str | None, nouveau: str) -> str:
if not existant: if not existant:
return nouveau return nouveau
+237
View File
@@ -0,0 +1,237 @@
"""Critères d'acceptation du cahier des charges (§7 et §8.7).
Ces tests exécutent le seed complet sur le corpus réel, puis vérifient en base
les sept garde-fous du §7 et les scénarios de démonstration du §8.7. Ils
échouent si une régression altère un fait — c'est leur seule raison d'être.
"""
from __future__ import annotations
import json
import sqlite3
import pytest
from pipeline import db
from pipeline.seed_from_research import ensemencer
@pytest.fixture(scope="module")
def base_complete(tmp_path_factory) -> sqlite3.Connection:
"""Base issue d'un seed complet du corpus, partagée par le module."""
chemin = tmp_path_factory.mktemp("acceptation") / "veille.db"
cx = db.initialiser(chemin)
ensemencer(cx)
return cx
def _texte(cx: sqlite3.Connection, identifiant: str) -> sqlite3.Row:
ligne = cx.execute("SELECT * FROM v_textes WHERE id = ?", (identifiant,)).fetchone()
assert ligne is not None, f"{identifiant} absent de la base"
return ligne
def _affaires(cx: sqlite3.Connection, identifiant: str) -> list[str]:
return [
r["numero_affaire"]
for r in cx.execute(
"SELECT numero_affaire FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire",
(identifiant,),
)
]
# ─────────────────────────────────────────────────────────────────────────────
# §2 — volumétrie
# ─────────────────────────────────────────────────────────────────────────────
def test_au_moins_49_textes(base_complete) -> None:
total = base_complete.execute("SELECT COUNT(*) FROM textes").fetchone()[0]
assert total >= 49
def test_chaque_texte_a_au_moins_une_source_url(base_complete) -> None:
"""§8.2 : 100 % des entrées avec au moins une source URL."""
orphelins = [
r["id"]
for r in base_complete.execute(
"SELECT id FROM textes t "
"WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)"
)
]
assert orphelins == []
def test_toutes_les_sources_ont_une_url_absolue(base_complete) -> None:
mauvaises = [
r["url"]
for r in base_complete.execute(
"SELECT url FROM sources WHERE url NOT LIKE 'http://%' AND url NOT LIKE 'https://%'"
)
]
assert mauvaises == []
# ─────────────────────────────────────────────────────────────────────────────
# §7 — les sept garde-fous
# ─────────────────────────────────────────────────────────────────────────────
def test_1_chlordecone(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-491")
assert texte["numero_officiel"] == "2026-491"
assert texte["statut"] == "promulguee"
assert texte["date_promulgation"] == "2026-06-12"
assert texte["guadeloupe_pertinence"] == "forte"
assert {"sante", "outre_mer", "memoire_patrimoine"} <= set(json.loads(texte["themes"]))
assert texte["nb_sources"] > 0
def test_2_fraudes_sociales_et_fiscales(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-534")
assert texte["statut"] == "promulguee"
assert texte["date_promulgation"] == "2026-06-25"
assert _affaires(base_complete, "loi-2026-534") == ["2026-904 DC"]
def test_3_loi_riposte(base_complete) -> None:
"""Statut, événement de saisine, et amende forfaitaire à 500 € — pas 1 500 €."""
texte = _texte(base_complete, "loi-riposte")
assert texte["statut"] == "adoptee_non_promulguee"
assert texte["numero_officiel"] is None
saisines = base_complete.execute(
"SELECT date_evenement, description FROM evenements "
"WHERE texte_id = 'loi-riposte' AND type_etape = 'saisine_cc'"
).fetchall()
assert len(saisines) == 1
assert saisines[0]["date_evenement"] == "2026-07-24"
assert "2026-915 DC" in saisines[0]["description"]
assert "24 juillet 2026" in saisines[0]["description"]
points = json.loads(texte["points_cles"])
assert any("500 €" in p for p in points), "l'AFD à 500 € doit figurer aux points clés"
# Le montant abandonné ne doit apparaître que présenté comme tel.
for point in points:
if "1 500 €" in point:
assert "abandonn" in point or "Sénat 1" in point
def test_4_ppl_aide_a_mourir(base_complete) -> None:
texte = _texte(base_complete, "ppl-aide-a-mourir")
assert texte["date_adoption"] == "2026-07-15"
assert _affaires(base_complete, "ppl-aide-a-mourir") == ["2026-910 DC"]
assert "291" in (texte["resume"] or ""), "le vote 291/241 doit apparaître au résumé"
def test_5_pjl_pacte_migration(base_complete) -> None:
texte = _texte(base_complete, "pjl-pacte-migration")
assert texte["statut"] == "navette"
assert "migration" in json.loads(texte["themes"])
def test_6_simplification_vie_economique(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-403")
assert texte["statut"] == "promulguee"
decision = base_complete.execute(
"SELECT * FROM decisions_cc WHERE texte_id = 'loi-2026-403'"
).fetchone()
assert decision["numero_affaire"] == "2026-903 DC"
assert decision["resultat"] == "non_conformite_partielle"
assert "25 articles" in decision["resume"]
note = (texte["guadeloupe_note"] or "").lower()
assert "march" in note and "ultramarin" in note
def test_7_senatoriales_la_guadeloupe_n_est_pas_concernee(base_complete) -> None:
"""Le point 4 de la validation de phase 5 tranche : série 1, renouvelée en 2023."""
echeance = base_complete.execute(
"SELECT * FROM echeances WHERE code = 'senatoriales-2026'"
).fetchone()
assert echeance is not None
assert echeance["date_echeance"] == "2026-09-27"
assert echeance["concerne_guadeloupe"] == 0
assert "n'est PAS concernée" in echeance["note_guadeloupe"]
assert "série 1" in echeance["note_guadeloupe"]
# ─────────────────────────────────────────────────────────────────────────────
# §8.7 — scénarios de démonstration
# ─────────────────────────────────────────────────────────────────────────────
def test_recherche_chlordecone_classe_la_bonne_loi_en_tete(base_complete) -> None:
lignes = base_complete.execute(
"""
SELECT t.id
FROM textes_fts f
JOIN textes t ON t.rowid = f.rowid
WHERE textes_fts MATCH 'chlordecone'
ORDER BY bm25(textes_fts, 10.0, 5.0, 3.0, 2.0, 2.0, 1.0)
"""
).fetchall()
assert lignes, "la recherche « chlordécone » ne doit pas être vide"
assert lignes[0]["id"] == "loi-2026-491"
def test_facette_devant_le_conseil_constitutionnel(base_complete) -> None:
total = base_complete.execute(
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1"
).fetchone()[0]
assert total >= 7
def test_facette_guadeloupe_forte_non_vide(base_complete) -> None:
total = base_complete.execute(
"SELECT COUNT(*) FROM textes WHERE guadeloupe_pertinence = 'forte'"
).fetchone()[0]
assert total > 0
def test_facettes_combinees(base_complete) -> None:
"""`/recherche?statut=saisie_cc&guadeloupe=forte` doit rendre des résultats."""
total = base_complete.execute(
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1 AND guadeloupe_pertinence = 'forte'"
).fetchone()[0]
assert total > 0
def test_une_fiche_a_timeline_et_sources(base_complete) -> None:
texte = _texte(base_complete, "loi-riposte")
assert texte["nb_evenements"] >= 2
assert texte["nb_sources"] >= 3
# ─────────────────────────────────────────────────────────────────────────────
# Honnêteté des données
# ─────────────────────────────────────────────────────────────────────────────
def test_les_deductions_sont_signalees(base_complete) -> None:
"""Toute valeur déduite porte un motif de vérification lisible."""
for ligne in base_complete.execute("SELECT id, motif_verification FROM textes WHERE a_verifier = 1"):
assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif"
def test_aucune_pertinence_manuelle_n_a_ete_ecrasee(base_complete) -> None:
"""Les 27 lois promulguées gardent la cotation de l'annexe (§5.3)."""
deduites = base_complete.execute(
"SELECT COUNT(*) FROM textes WHERE statut = 'promulguee' "
"AND motif_verification LIKE '%Pertinence Guadeloupe déduite%'"
).fetchone()[0]
assert deduites == 0
def test_analyses_transversales_importees(base_complete) -> None:
total = base_complete.execute("SELECT COUNT(*) FROM insights").fetchone()[0]
assert total == 7
def test_echeances_du_calendrier(base_complete) -> None:
codes = {
r["code"] for r in base_complete.execute("SELECT code FROM echeances")
}
assert {"senatoriales-2026", "plf-2027", "facturation-electronique-reception"} <= codes