diff --git a/pipeline/db.py b/pipeline/db.py index 39de9bf..2bd1d26 100644 --- a/pipeline/db.py +++ b/pipeline/db.py @@ -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: cx.execute( """ @@ -332,6 +366,7 @@ def statistiques(cx: sqlite3.Connection) -> dict[str, Any]: "evenements": un("SELECT COUNT(*) FROM evenements"), "decisions_cc": un("SELECT COUNT(*) FROM decisions_cc"), "insights": un("SELECT COUNT(*) FROM insights"), + "echeances": un("SELECT COUNT(*) FROM echeances"), "textes_sans_source": un( "SELECT COUNT(*) FROM textes t " "WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)" diff --git a/pipeline/impacts.py b/pipeline/impacts.py new file mode 100644 index 0000000..95e28f6 --- /dev/null +++ b/pipeline/impacts.py @@ -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"(? 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 "" diff --git a/pipeline/migrations/004_echeances.sql b/pipeline/migrations/004_echeances.sql new file mode 100644 index 0000000..52959ba --- /dev/null +++ b/pipeline/migrations/004_echeances.sql @@ -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); diff --git a/pipeline/parseurs/chapitres.py b/pipeline/parseurs/chapitres.py index d381e9c..ec52794 100644 --- a/pipeline/parseurs/chapitres.py +++ b/pipeline/parseurs/chapitres.py @@ -136,7 +136,13 @@ def parser() -> LectureChapitres: nom = f"sec{numero:02d}" 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 phrase in _phrases(paragraphe): diff --git a/pipeline/parseurs/echeances.py b/pipeline/parseurs/echeances.py new file mode 100644 index 0000000..5469bb1 --- /dev/null +++ b/pipeline/parseurs/echeances.py @@ -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 à 2–20 € 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 diff --git a/pipeline/resolution_conflits.py b/pipeline/resolution_conflits.py new file mode 100644 index 0000000..ebefca5 --- /dev/null +++ b/pipeline/resolution_conflits.py @@ -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 diff --git a/pipeline/seed_from_research.py b/pipeline/seed_from_research.py index 5bd1d4e..a6bda89 100644 --- a/pipeline/seed_from_research.py +++ b/pipeline/seed_from_research.py @@ -24,7 +24,7 @@ import sqlite3 import time 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.modeles import Source, Texte from pipeline.parseurs import ( @@ -32,6 +32,7 @@ from pipeline.parseurs import ( bibliographie, chapitres, citations, + echeances, tableaux_sec10, ) @@ -50,10 +51,13 @@ class CompteRendu: evenements: int = 0 decisions_cc: int = 0 analyses: int = 0 + echeances: int = 0 textes_sans_source: list[str] = field(default_factory=list) textes_sans_resume: 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) marqueurs_avec_lien: int = 0 @@ -69,6 +73,7 @@ class CompteRendu: f" événements : {self.evenements}", f" décisions CC : {self.decisions_cc}", f" analyses : {self.analyses}", + f" échéances : {self.echeances}", "", "Résolution des citations", f" avec lien : {self.marqueurs_avec_lien}", @@ -81,6 +86,8 @@ class CompteRendu: f" textes sans résumé : {len(self.textes_sans_resume)}" + (f" → {', '.join(self.textes_sans_resume)}" if self.textes_sans_resume else ""), 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: lignes.append("") @@ -114,6 +121,7 @@ def ensemencer(cx: sqlite3.Connection) -> CompteRendu: _rattacher_sources(texte, repertoire, lecture, marqueurs_annexe) _rattacher_redaction(texte, lecture) _coter_guadeloupe(texte, compte) + _ventiler_impacts(texte, lecture, compte) if not texte.sources: 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_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()) lectures = analyses.parser(chemins.INSIGHTS.read_text(encoding="utf-8")) with db.transaction(cx): for analyse in lectures: db.enregistrer_insight(cx, analyse) + for echeance in echeances.echeances_nationales(): + db.enregistrer_echeance(cx, echeance) statistiques = db.statistiques(cx) compte.textes = statistiques["textes"] @@ -144,6 +161,7 @@ def ensemencer(cx: sqlite3.Connection) -> CompteRendu: compte.evenements = statistiques["evenements"] compte.decisions_cc = statistiques["decisions_cc"] compte.analyses = statistiques["insights"] + compte.echeances = statistiques["echeances"] if compte.textes < TEXTES_ATTENDUS_MINIMUM: compte.avertissements.append( @@ -234,6 +252,23 @@ def _coter_guadeloupe(texte: Texte, compte: CompteRendu) -> None: 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: if not existant: return nouveau diff --git a/tests/test_acceptation.py b/tests/test_acceptation.py new file mode 100644 index 0000000..f5cd6d4 --- /dev/null +++ b/tests/test_acceptation.py @@ -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