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:
co-authored by
Claude Opus 5
parent
c28243cf87
commit
f3e546cfc4
@@ -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)"
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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);
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user