Phase 1 : socle Python, schéma SQLite + FTS5, modèles pydantic

- pyproject/uv sur Python 3.12, dépendances épinglées
- migrations versionnées (PRAGMA user_version) ; 001_initial pose textes,
  sources, evenements, decisions_cc, insights, veille_log, veille_changements
- index FTS5 à contenu externe, tokenizer unicode61 remove_diacritics 2 :
  « chlordecone » retrouve « chlordécone », highlight() disponible
- vue v_textes : devant_cc combine le statut et les affaires CC en instance,
  ce qui réconcilie le §7 (Riposte reste adoptee_non_promulguee) et le §8.7
  (la facette saisie_cc doit remonter ≥ 7 textes)
- modèles pydantic refusant les incohérences de statut : promulguée sans
  numéro, navette avec numéro officiel, slug malformé
- Makefile, .env.example, journalisation structlog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyber Mawonaj
2026-07-25 17:49:11 -04:00
co-authored by Claude Opus 5
parent 3ce7d17db9
commit 31d3935bce
9 changed files with 1316 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Pipeline de veille législative française — impacts libertés, zoom Guadeloupe."""
__version__ = "1.0.0"
# Date d'arrêté du corpus de recherche servant de graine. Tout événement
# postérieur à cette date est prévisionnel tant qu'un collecteur ne l'a pas
# confirmé sur une source officielle.
DATE_ARRETE_CORPUS = "2026-07-25"
+66
View File
@@ -0,0 +1,66 @@
"""Localisation des fichiers du projet.
Un unique point de vérité pour les chemins, afin que les parseurs, les tests et
les collecteurs ne fassent jamais d'hypothèse sur le répertoire courant.
"""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
# pipeline/chemins.py → pipeline/ → racine du dépôt
RACINE = Path(__file__).resolve().parent.parent
load_dotenv(RACINE / ".env")
# ── Corpus d'entrée — LECTURE SEULE ──────────────────────────────────────────
ENTREE = RACINE / "data" / "input"
RECHERCHE = ENTREE / "research"
RAPPORT_FINAL = ENTREE / "lois-france-2026-guadeloupe.agent.final.md"
RAPPORT_OUTLINE = ENTREE / "lois-france-2026-guadeloupe.agent.outline.md"
BIBLIOGRAPHIE = ENTREE / "lois-france-2026-guadeloupe_ref.md"
CROSS_VERIFICATION = RECHERCHE / "lois-2026_cross_verification.md"
INSIGHTS = RECHERCHE / "lois-2026_insight.md"
PHASE5_VALIDATION = RECHERCHE / "lois-2026_phase5_validation.md"
def chapitre(numero: int) -> Path:
"""Chemin d'un chapitre du rapport : `chapitre(10)` → `…_sec10.md`."""
return ENTREE / f"lois-france-2026-guadeloupe_sec{numero:02d}.md"
def dimension(numero: int) -> Path:
"""Chemin d'un rapport de dimension : `dimension(7)` → `lois-2026_dim07.md`."""
return RECHERCHE / f"lois-2026_dim{numero:02d}.md"
CHAPITRES = [chapitre(n) for n in range(11)]
DIMENSIONS = [dimension(n) for n in range(1, 13)]
# ── Sorties ──────────────────────────────────────────────────────────────────
DONNEES = RACINE / "data"
BASE_SQLITE = RACINE / os.environ.get("VEILLE_DB", "data/veille.db")
DUMP_JSON = DONNEES / "textes.json"
CACHE_HTTP = RACINE / os.environ.get("VEILLE_CACHE", "data/cache_http")
MIGRATIONS = Path(__file__).resolve().parent / "migrations"
CONFIG = Path(__file__).resolve().parent / "config.yaml"
def verifier_corpus() -> list[str]:
"""Retourne la liste des fichiers d'entrée attendus mais absents."""
attendus = [
RAPPORT_FINAL,
BIBLIOGRAPHIE,
CROSS_VERIFICATION,
INSIGHTS,
PHASE5_VALIDATION,
*CHAPITRES,
*DIMENSIONS,
]
return [str(p.relative_to(RACINE)) for p in attendus if not p.exists()]
+381
View File
@@ -0,0 +1,381 @@
"""Accès SQLite : migrations versionnées, écriture des textes, requêtes de veille.
Une seule règle : rien n'entre en base sans passer par un modèle pydantic
validé. Les fonctions de ce module reçoivent des `Texte`, jamais des
dictionnaires bruts venus d'un parseur.
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from pipeline import chemins
from pipeline.journal import logger
from pipeline.modeles import DecisionCC, Evenement, Insight, Source, Texte
log = logger("db")
# ─────────────────────────────────────────────────────────────────────────────
# Connexion et migrations
# ─────────────────────────────────────────────────────────────────────────────
def connecter(base: Path | None = None, *, lecture_seule: bool = False) -> sqlite3.Connection:
"""Ouvre la base avec les réglages attendus par le reste du pipeline."""
base = base or chemins.BASE_SQLITE
base.parent.mkdir(parents=True, exist_ok=True)
if lecture_seule:
cx = sqlite3.connect(f"file:{base}?mode=ro", uri=True)
else:
cx = sqlite3.connect(base)
cx.row_factory = sqlite3.Row
cx.execute("PRAGMA foreign_keys = ON")
if not lecture_seule:
cx.execute("PRAGMA journal_mode = WAL")
cx.execute("PRAGMA synchronous = NORMAL")
return cx
@contextmanager
def transaction(cx: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Valide à la sortie, annule intégralement à la moindre exception."""
try:
yield cx
except Exception:
cx.rollback()
raise
else:
cx.commit()
def _version_schema(cx: sqlite3.Connection) -> int:
return int(cx.execute("PRAGMA user_version").fetchone()[0])
def migrer(cx: sqlite3.Connection, dossier: Path | None = None) -> list[str]:
"""Applique les migrations non encore jouées, dans l'ordre des noms.
Retourne la liste des migrations appliquées lors de cet appel.
"""
dossier = dossier or chemins.MIGRATIONS
fichiers = sorted(dossier.glob("*.sql"))
version = _version_schema(cx)
appliquees: list[str] = []
for fichier in fichiers:
numero = int(fichier.name.split("_", 1)[0])
if numero <= version:
continue
log.info("migration", fichier=fichier.name, numero=numero)
cx.executescript(fichier.read_text(encoding="utf-8"))
cx.execute(f"PRAGMA user_version = {numero}")
cx.commit()
appliquees.append(fichier.name)
return appliquees
def initialiser(base: Path | None = None, *, repartir_de_zero: bool = False) -> sqlite3.Connection:
"""Ouvre la base et s'assure que le schéma est à jour."""
base = base or chemins.BASE_SQLITE
if repartir_de_zero and base.exists():
for suffixe in ("", "-wal", "-shm"):
Path(str(base) + suffixe).unlink(missing_ok=True)
log.info("base supprimée avant réinitialisation", base=str(base))
cx = connecter(base)
migrer(cx)
return cx
# ─────────────────────────────────────────────────────────────────────────────
# Écriture
# ─────────────────────────────────────────────────────────────────────────────
_CHAMPS_TEXTE = (
"id", "numero_officiel", "type", "titre_court", "titre_officiel",
"statut", "statut_date", "date_depot", "date_adoption", "date_promulgation",
"date_entree_vigueur", "prochaine_echeance", "prochaine_echeance_label",
"themes", "impacts", "guadeloupe_pertinence", "guadeloupe_note",
"resume", "points_cles", "confiance", "source_seed",
"a_verifier", "motif_verification",
)
def enregistrer_texte(cx: sqlite3.Connection, texte: Texte) -> None:
"""Insère ou met à jour un texte, avec ses sources, événements et décisions."""
ligne = texte.en_ligne_sql()
colonnes = ", ".join(_CHAMPS_TEXTE)
valeurs = ", ".join(f":{c}" for c in _CHAMPS_TEXTE)
maj = ", ".join(f"{c} = excluded.{c}" for c in _CHAMPS_TEXTE if c != "id")
cx.execute(
f"INSERT INTO textes ({colonnes}) VALUES ({valeurs}) "
f"ON CONFLICT (id) DO UPDATE SET {maj}, maj_le = datetime('now')",
ligne,
)
for source in texte.sources:
enregistrer_source(cx, texte.id, source)
for evenement in texte.evenements:
enregistrer_evenement(cx, texte.id, evenement)
for decision in texte.decisions_cc:
enregistrer_decision_cc(cx, decision, texte_id=texte.id)
def enregistrer_source(cx: sqlite3.Connection, texte_id: str, source: Source) -> None:
cx.execute(
"""
INSERT INTO sources (texte_id, url, titre, editeur, date_publication, tier,
extrait_verbatim, contexte, confiance, marqueur, fichier_origine)
VALUES (:texte_id, :url, :titre, :editeur, :date_publication, :tier,
:extrait_verbatim, :contexte, :confiance, :marqueur, :fichier_origine)
ON CONFLICT (texte_id, url, COALESCE(marqueur, '')) DO UPDATE SET
titre = COALESCE(excluded.titre, titre),
editeur = COALESCE(excluded.editeur, editeur),
date_publication = COALESCE(excluded.date_publication, date_publication),
tier = excluded.tier,
extrait_verbatim = COALESCE(excluded.extrait_verbatim, extrait_verbatim),
contexte = COALESCE(excluded.contexte, contexte),
confiance = COALESCE(excluded.confiance, confiance)
""",
{
"texte_id": texte_id,
"url": source.url,
"titre": source.titre,
"editeur": source.editeur,
"date_publication": source.date_publication.isoformat()
if source.date_publication
else None,
"tier": str(source.tier),
"extrait_verbatim": source.extrait_verbatim,
"contexte": source.contexte,
"confiance": str(source.confiance) if source.confiance else None,
"marqueur": source.marqueur,
"fichier_origine": source.fichier_origine,
},
)
def enregistrer_evenement(cx: sqlite3.Connection, texte_id: str, evenement: Evenement) -> None:
cx.execute(
"""
INSERT INTO evenements (texte_id, date_evenement, type_etape, description,
source_url, previsionnel)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (texte_id, date_evenement, type_etape, description) DO UPDATE SET
source_url = COALESCE(excluded.source_url, source_url),
previsionnel = excluded.previsionnel
""",
(
texte_id,
evenement.date_evenement.isoformat(),
str(evenement.type_etape),
evenement.description,
evenement.source_url,
int(evenement.previsionnel),
),
)
def enregistrer_decision_cc(
cx: sqlite3.Connection, decision: DecisionCC, *, texte_id: str | None = None
) -> None:
cx.execute(
"""
INSERT INTO decisions_cc (texte_id, numero_affaire, date_saisine, date_decision,
resultat, saisissants, resume, url, date_decision_attendue)
VALUES (:texte_id, :numero_affaire, :date_saisine, :date_decision,
:resultat, :saisissants, :resume, :url, :date_decision_attendue)
ON CONFLICT (numero_affaire) DO UPDATE SET
texte_id = COALESCE(excluded.texte_id, texte_id),
date_saisine = COALESCE(excluded.date_saisine, date_saisine),
date_decision = COALESCE(excluded.date_decision, date_decision),
resultat = COALESCE(excluded.resultat, resultat),
saisissants = COALESCE(excluded.saisissants, saisissants),
resume = COALESCE(excluded.resume, resume),
url = COALESCE(excluded.url, url),
date_decision_attendue = COALESCE(excluded.date_decision_attendue,
date_decision_attendue),
maj_le = datetime('now')
""",
{
"texte_id": texte_id,
"numero_affaire": decision.numero_affaire,
"date_saisine": decision.date_saisine.isoformat() if decision.date_saisine else None,
"date_decision": decision.date_decision.isoformat()
if decision.date_decision
else None,
"resultat": str(decision.resultat) if decision.resultat else None,
"saisissants": decision.saisissants,
"resume": decision.resume,
"url": decision.url,
"date_decision_attendue": decision.date_decision_attendue.isoformat()
if decision.date_decision_attendue
else None,
},
)
def enregistrer_insight(cx: sqlite3.Connection, insight: Insight) -> None:
cx.execute(
"""
INSERT INTO insights (numero, titre, corps, implications, confiance,
derive_de, fichier_origine)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (numero) DO UPDATE SET
titre = excluded.titre, corps = excluded.corps,
implications = excluded.implications, confiance = excluded.confiance,
derive_de = excluded.derive_de, fichier_origine = excluded.fichier_origine
""",
(
insight.numero,
insight.titre,
insight.corps,
insight.implications,
insight.confiance,
json.dumps(insight.derive_de, ensure_ascii=False),
insight.fichier_origine,
),
)
def enregistrer_textes(cx: sqlite3.Connection, textes: Iterable[Texte]) -> int:
"""Écrit un lot de textes dans une transaction unique."""
total = 0
with transaction(cx):
for texte in textes:
enregistrer_texte(cx, texte)
total += 1
return total
# ─────────────────────────────────────────────────────────────────────────────
# Journal des exécutions
# ─────────────────────────────────────────────────────────────────────────────
def ouvrir_run(cx: sqlite3.Connection, mode: str) -> int:
curseur = cx.execute("INSERT INTO veille_log (mode) VALUES (?)", (mode,))
cx.commit()
return int(curseur.lastrowid)
def cloturer_run(
cx: sqlite3.Connection,
run_id: int,
*,
duree_s: float,
ajouts: int = 0,
modifications: int = 0,
echecs: int = 0,
alertes: list[str] | None = None,
rapport: str | None = None,
) -> None:
cx.execute(
"""
UPDATE veille_log
SET duree_s = ?, ajouts = ?, modifications = ?, echecs = ?,
alertes = ?, rapport = ?
WHERE id = ?
""",
(
duree_s,
ajouts,
modifications,
echecs,
json.dumps(alertes or [], ensure_ascii=False),
rapport,
run_id,
),
)
cx.commit()
def enregistrer_changement(
cx: sqlite3.Connection,
run_id: int,
*,
texte_id: str | None,
nature: str,
champ: str | None = None,
ancienne_valeur: str | None = None,
nouvelle_valeur: str | None = None,
description: str | None = None,
) -> None:
cx.execute(
"""
INSERT INTO veille_changements (run_id, texte_id, nature, champ,
ancienne_valeur, nouvelle_valeur, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(run_id, texte_id, nature, champ, ancienne_valeur, nouvelle_valeur, description),
)
# ─────────────────────────────────────────────────────────────────────────────
# Lecture
# ─────────────────────────────────────────────────────────────────────────────
def statistiques(cx: sqlite3.Connection) -> dict[str, Any]:
"""Compteurs affichés en fin de seed et sur le tableau de bord."""
def un(requete: str) -> int:
return int(cx.execute(requete).fetchone()[0])
return {
"textes": un("SELECT COUNT(*) FROM textes"),
"sources": un("SELECT COUNT(*) FROM sources"),
"evenements": un("SELECT COUNT(*) FROM evenements"),
"decisions_cc": un("SELECT COUNT(*) FROM decisions_cc"),
"insights": un("SELECT COUNT(*) FROM insights"),
"textes_sans_source": un(
"SELECT COUNT(*) FROM textes t "
"WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)"
),
"a_verifier": un("SELECT COUNT(*) FROM textes WHERE a_verifier = 1"),
"par_statut": {
r["statut"]: r["n"]
for r in cx.execute(
"SELECT statut, COUNT(*) AS n FROM textes GROUP BY statut ORDER BY n DESC"
)
},
"par_pertinence_gpe": {
(r["guadeloupe_pertinence"] or "non_cotee"): r["n"]
for r in cx.execute(
"SELECT guadeloupe_pertinence, COUNT(*) AS n FROM textes "
"GROUP BY guadeloupe_pertinence"
)
},
"devant_cc": un("SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1"),
}
def main() -> None:
"""`make db-init` — crée la base si besoin et applique les migrations."""
import argparse
from pipeline.journal import configurer
analyseur = argparse.ArgumentParser(description="Initialise ou migre la base SQLite.")
analyseur.add_argument(
"--repartir-de-zero",
action="store_true",
help="supprime la base existante avant de recréer le schéma",
)
arguments = analyseur.parse_args()
configurer()
cx = initialiser(repartir_de_zero=arguments.repartir_de_zero)
log.info(
"base prête",
chemin=str(chemins.BASE_SQLITE),
version_schema=_version_schema(cx),
**statistiques(cx) | {"par_statut": None, "par_pertinence_gpe": None},
)
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""Journalisation structurée du pipeline.
Sortie lisible en console pour les exécutions manuelles, JSON lorsque la sortie
n'est pas un terminal (systemd, redirections) afin que `journalctl` reste
exploitable.
"""
from __future__ import annotations
import logging
import sys
import structlog
def configurer(niveau: str = "INFO") -> None:
"""Configure structlog une fois pour toute l'exécution."""
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=niveau)
processeurs: list = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
]
if sys.stdout.isatty():
processeurs.append(structlog.dev.ConsoleRenderer(colors=True))
else:
processeurs.append(structlog.processors.JSONRenderer(ensure_ascii=False))
structlog.configure(
processors=processeurs,
wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, niveau)),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def logger(nom: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(nom)
+276
View File
@@ -0,0 +1,276 @@
-- ============================================================================
-- Migration 001 — schéma initial de la veille législative
--
-- Sémantique conforme au §4 du prompt de mission. Les libellés d'énumération
-- sont ceux du corpus de recherche : ils ne sont pas traduits ni normalisés,
-- de façon à ce qu'une valeur en base soit toujours retrouvable dans un
-- fichier de `data/input/`.
-- ============================================================================
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- ─────────────────────────────────────────────────────────────────────────────
-- Table principale : un texte législatif ou une décision juridictionnelle
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE textes (
-- Identifiant lisible et stable, servant aussi d'URL : « loi-2026-491 ».
id TEXT PRIMARY KEY,
-- Numéro officiel une fois attribué (« 2026-491 »). NULL tant que le texte
-- n'est pas promulgué : c'est précisément ce qui distingue un texte adopté
-- d'un texte en vigueur.
numero_officiel TEXT,
type TEXT NOT NULL CHECK (type IN (
'loi', 'loi_organique', 'pjl', 'ppl', 'ordonnance',
'decision_cc', 'decret', 'accord_international')),
titre_court TEXT NOT NULL,
titre_officiel TEXT,
-- Règle d'or du rapport : le statut prime sur l'intitulé.
statut TEXT NOT NULL CHECK (statut IN (
'promulguee', 'adoptee_non_promulguee', 'saisie_cc',
'validee_cc', 'censuree_partiellement', 'navette',
'deposee_non_examinee', 'annoncee', 'rejetee')),
statut_date TEXT, -- date du dernier changement de statut (ISO 8601)
date_depot TEXT,
date_adoption TEXT,
date_promulgation TEXT,
date_entree_vigueur TEXT,
prochaine_echeance TEXT,
prochaine_echeance_label TEXT,
-- Tableaux JSON. SQLite les stocke en texte ; les fonctions json_*
-- permettent de filtrer sans table de jointure supplémentaire.
themes TEXT NOT NULL DEFAULT '[]',
-- Objet JSON : { "individus": {"sens": "negatif", "note": "…"}, … }
-- Sens admis : positif | negatif | mixte | neutre.
impacts TEXT NOT NULL DEFAULT '{}',
guadeloupe_pertinence TEXT CHECK (guadeloupe_pertinence IN ('forte', 'moyenne', 'faible')),
guadeloupe_note TEXT,
resume TEXT,
points_cles TEXT NOT NULL DEFAULT '[]',
confiance TEXT NOT NULL DEFAULT 'medium'
CHECK (confiance IN ('high', 'medium', 'low')),
-- Fichier de `data/input/` dont provient l'entrée, ou nom du collecteur.
source_seed TEXT,
-- Drapeau de revue humaine : toute classification automatique le lève.
a_verifier INTEGER NOT NULL DEFAULT 0 CHECK (a_verifier IN (0, 1)),
motif_verification TEXT,
derniere_verif TEXT,
cree_le TEXT NOT NULL DEFAULT (datetime('now')),
maj_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_textes_numero ON textes (numero_officiel)
WHERE numero_officiel IS NOT NULL;
CREATE INDEX idx_textes_statut ON textes (statut);
CREATE INDEX idx_textes_type ON textes (type);
CREATE INDEX idx_textes_gpe ON textes (guadeloupe_pertinence);
CREATE INDEX idx_textes_promulgation ON textes (date_promulgation);
CREATE INDEX idx_textes_echeance ON textes (prochaine_echeance);
-- ─────────────────────────────────────────────────────────────────────────────
-- Sources : une citation vérifiable par fait important
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
texte_id TEXT NOT NULL REFERENCES textes (id) ON DELETE CASCADE,
url TEXT NOT NULL,
titre TEXT,
editeur TEXT,
date_publication TEXT,
-- T1 = source primaire (Légifrance/JO, AN, Sénat, Conseil constitutionnel,
-- Conseil d'État, vie-publique, préfectures) ; T2 = presse et analyses.
tier TEXT NOT NULL DEFAULT 'T2' CHECK (tier IN ('T1', 'T2')),
extrait_verbatim TEXT,
contexte TEXT,
confiance TEXT CHECK (confiance IN ('high', 'medium', 'low')),
-- Marqueur d'origine dans le corpus : « dim07-24 », « phase5-3.1 », « ref-481 ».
marqueur TEXT,
fichier_origine TEXT,
cree_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sources_texte ON sources (texte_id);
CREATE UNIQUE INDEX idx_sources_unicite ON sources (texte_id, url, COALESCE(marqueur, ''));
-- ─────────────────────────────────────────────────────────────────────────────
-- Événements : la timeline parlementaire d'un texte
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE evenements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
texte_id TEXT NOT NULL REFERENCES textes (id) ON DELETE CASCADE,
date_evenement TEXT NOT NULL,
type_etape TEXT NOT NULL CHECK (type_etape IN (
'depot', 'adoption_1re_lecture', 'adoption_definitive',
'commission_mixte_paritaire', 'transmission', 'saisine_cc',
'decision_cc', 'promulgation', 'publication_jo',
'entree_vigueur', 'rejet', 'annonce', 'autre')),
description TEXT NOT NULL,
source_url TEXT,
-- Vrai lorsque l'événement est postérieur à la date d'arrêté des données :
-- il s'agit alors d'une échéance prévisionnelle, pas d'un fait acquis.
previsionnel INTEGER NOT NULL DEFAULT 0 CHECK (previsionnel IN (0, 1)),
cree_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_evenements_texte ON evenements (texte_id, date_evenement);
CREATE INDEX idx_evenements_date ON evenements (date_evenement);
CREATE UNIQUE INDEX idx_evenements_unicite
ON evenements (texte_id, date_evenement, type_etape, description);
-- ─────────────────────────────────────────────────────────────────────────────
-- Décisions du Conseil constitutionnel
--
-- `date_decision IS NULL` signifie « affaire en instance » : c'est ce qui
-- alimente la facette « saisi du Conseil constitutionnel » de l'interface.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE decisions_cc (
id INTEGER PRIMARY KEY AUTOINCREMENT,
texte_id TEXT REFERENCES textes (id) ON DELETE SET NULL,
numero_affaire TEXT NOT NULL UNIQUE, -- « 2026-915 DC »
date_saisine TEXT,
date_decision TEXT,
resultat TEXT CHECK (resultat IN (
'conforme', 'conforme_avec_reserves', 'non_conformite_partielle',
'non_conformite_totale', 'en_instance')),
saisissants TEXT,
resume TEXT,
url TEXT,
date_decision_attendue TEXT, -- prévisionnel, tant que la décision n'est pas rendue
cree_le TEXT NOT NULL DEFAULT (datetime('now')),
maj_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_cc_texte ON decisions_cc (texte_id);
CREATE INDEX idx_cc_instance ON decisions_cc (date_decision);
-- ─────────────────────────────────────────────────────────────────────────────
-- Analyses transversales issues de `lois-2026_insight.md`
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE insights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
numero INTEGER NOT NULL UNIQUE,
titre TEXT NOT NULL,
corps TEXT NOT NULL,
implications TEXT,
confiance TEXT NOT NULL DEFAULT 'medium',
derive_de TEXT NOT NULL DEFAULT '[]', -- JSON : ["dim02", "dim03", …]
fichier_origine TEXT
);
-- ─────────────────────────────────────────────────────────────────────────────
-- Journal des exécutions du pipeline
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE veille_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
horodatage TEXT NOT NULL DEFAULT (datetime('now')),
mode TEXT NOT NULL CHECK (mode IN ('seed', 'update', 'dry-run')),
duree_s REAL,
ajouts INTEGER NOT NULL DEFAULT 0,
modifications INTEGER NOT NULL DEFAULT 0,
echecs INTEGER NOT NULL DEFAULT 0,
alertes TEXT NOT NULL DEFAULT '[]', -- JSON
rapport TEXT -- compte-rendu lisible
);
CREATE INDEX idx_veille_log_date ON veille_log (horodatage DESC);
-- Détail des changements d'un run, pour l'encart « derniers changements ».
CREATE TABLE veille_changements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES veille_log (id) ON DELETE CASCADE,
texte_id TEXT,
nature TEXT NOT NULL CHECK (nature IN ('ajout', 'statut', 'date', 'source', 'autre')),
champ TEXT,
ancienne_valeur TEXT,
nouvelle_valeur TEXT,
description TEXT
);
CREATE INDEX idx_changements_run ON veille_changements (run_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- Recherche plein texte
--
-- Table FTS5 à contenu externe : l'index ne duplique pas les données, il est
-- tenu à jour par les déclencheurs ci-dessous. `remove_diacritics 2` permet de
-- trouver « chlordécone » en tapant « chlordecone ».
-- ─────────────────────────────────────────────────────────────────────────────
CREATE VIRTUAL TABLE textes_fts USING fts5 (
titre_court,
titre_officiel,
resume,
points_cles,
guadeloupe_note,
numero_officiel,
content = 'textes',
content_rowid = 'rowid',
tokenize = "unicode61 remove_diacritics 2"
);
CREATE TRIGGER textes_fts_ai AFTER INSERT ON textes BEGIN
INSERT INTO textes_fts (rowid, titre_court, titre_officiel, resume, points_cles,
guadeloupe_note, numero_officiel)
VALUES (new.rowid, new.titre_court, new.titre_officiel, new.resume, new.points_cles,
new.guadeloupe_note, new.numero_officiel);
END;
CREATE TRIGGER textes_fts_ad AFTER DELETE ON textes BEGIN
INSERT INTO textes_fts (textes_fts, rowid, titre_court, titre_officiel, resume,
points_cles, guadeloupe_note, numero_officiel)
VALUES ('delete', old.rowid, old.titre_court, old.titre_officiel, old.resume,
old.points_cles, old.guadeloupe_note, old.numero_officiel);
END;
CREATE TRIGGER textes_fts_au AFTER UPDATE ON textes BEGIN
INSERT INTO textes_fts (textes_fts, rowid, titre_court, titre_officiel, resume,
points_cles, guadeloupe_note, numero_officiel)
VALUES ('delete', old.rowid, old.titre_court, old.titre_officiel, old.resume,
old.points_cles, old.guadeloupe_note, old.numero_officiel);
INSERT INTO textes_fts (rowid, titre_court, titre_officiel, resume, points_cles,
guadeloupe_note, numero_officiel)
VALUES (new.rowid, new.titre_court, new.titre_officiel, new.resume, new.points_cles,
new.guadeloupe_note, new.numero_officiel);
END;
-- ─────────────────────────────────────────────────────────────────────────────
-- Vue de consultation : ajoute les informations dérivées dont l'interface a
-- besoin sans dupliquer d'état en base.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE VIEW v_textes AS
SELECT
t.*,
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
-- Un texte est « devant le Conseil constitutionnel » soit par son statut,
-- soit parce qu'une affaire le concernant est en instance. Les deux voies
-- sont nécessaires : le §7 du prompt impose que la loi « Riposte » garde le
-- statut `adoptee_non_promulguee`, alors qu'elle doit apparaître dans la
-- facette « saisi du Conseil constitutionnel ».
CASE WHEN t.statut = 'saisie_cc'
OR EXISTS (SELECT 1 FROM decisions_cc d
WHERE d.texte_id = t.id AND d.date_decision IS NULL)
THEN 1 ELSE 0 END AS devant_cc
FROM textes t;
+380
View File
@@ -0,0 +1,380 @@
"""Modèles de domaine validés par pydantic.
Le vocabulaire est celui du corpus de recherche, en français : un `Texte` a un
`statut`, une `pertinence` guadeloupéenne et des `impacts` sur trois publics.
Les énumérations reprennent exactement les valeurs du §4 du prompt de mission
et les contraintes CHECK de la migration 001.
"""
from __future__ import annotations
import json
import re
from datetime import date
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ─────────────────────────────────────────────────────────────────────────────
# Énumérations
# ─────────────────────────────────────────────────────────────────────────────
class TypeTexte(StrEnum):
LOI = "loi"
LOI_ORGANIQUE = "loi_organique"
PJL = "pjl"
PPL = "ppl"
ORDONNANCE = "ordonnance"
DECISION_CC = "decision_cc"
DECRET = "decret"
ACCORD_INTERNATIONAL = "accord_international"
class Statut(StrEnum):
"""Règle d'or du rapport : le statut prime sur l'intitulé."""
PROMULGUEE = "promulguee"
ADOPTEE_NON_PROMULGUEE = "adoptee_non_promulguee"
SAISIE_CC = "saisie_cc"
VALIDEE_CC = "validee_cc"
CENSUREE_PARTIELLEMENT = "censuree_partiellement"
NAVETTE = "navette"
DEPOSEE_NON_EXAMINEE = "deposee_non_examinee"
ANNONCEE = "annoncee"
REJETEE = "rejetee"
class Theme(StrEnum):
JUSTICE = "justice"
SECURITE = "securite"
NUMERIQUE = "numerique"
SOCIAL = "social"
FISCAL = "fiscal"
ENVIRONNEMENT = "environnement"
AGRICULTURE = "agriculture"
SANTE = "sante"
MEMOIRE_PATRIMOINE = "memoire_patrimoine"
OUTRE_MER = "outre_mer"
ECONOMIE = "economie"
MIGRATION = "migration"
INSTITUTIONS = "institutions"
class Public(StrEnum):
"""Les trois publics dont la mission suit les libertés."""
INDIVIDUS = "individus"
ASSOCIATIONS = "associations"
ENTREPRISES = "entreprises"
class SensImpact(StrEnum):
POSITIF = "positif"
NEGATIF = "negatif"
MIXTE = "mixte"
NEUTRE = "neutre"
class Pertinence(StrEnum):
FORTE = "forte"
MOYENNE = "moyenne"
FAIBLE = "faible"
class Confiance(StrEnum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class Tier(StrEnum):
"""T1 = source primaire institutionnelle ; T2 = presse et analyses."""
T1 = "T1"
T2 = "T2"
class TypeEtape(StrEnum):
DEPOT = "depot"
ADOPTION_1RE_LECTURE = "adoption_1re_lecture"
ADOPTION_DEFINITIVE = "adoption_definitive"
COMMISSION_MIXTE_PARITAIRE = "commission_mixte_paritaire"
TRANSMISSION = "transmission"
SAISINE_CC = "saisine_cc"
DECISION_CC = "decision_cc"
PROMULGATION = "promulgation"
PUBLICATION_JO = "publication_jo"
ENTREE_VIGUEUR = "entree_vigueur"
REJET = "rejet"
ANNONCE = "annonce"
AUTRE = "autre"
class ResultatCC(StrEnum):
CONFORME = "conforme"
CONFORME_AVEC_RESERVES = "conforme_avec_reserves"
NON_CONFORMITE_PARTIELLE = "non_conformite_partielle"
NON_CONFORMITE_TOTALE = "non_conformite_totale"
EN_INSTANCE = "en_instance"
# ─────────────────────────────────────────────────────────────────────────────
# Modèles
# ─────────────────────────────────────────────────────────────────────────────
MOTIF_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
MOTIF_NUMERO = re.compile(r"^\d{4}-\d{1,4}$")
MOTIF_AFFAIRE_CC = re.compile(r"^\d{4}-\d{1,4} DC$")
class Impact(BaseModel):
"""Effet d'un texte sur les libertés d'un public donné."""
model_config = ConfigDict(extra="forbid")
sens: SensImpact
note: str = Field(default="", max_length=400)
class Source(BaseModel):
"""Citation vérifiable : sans URL, un fait n'entre pas en base."""
model_config = ConfigDict(extra="forbid")
url: str
titre: str | None = None
editeur: str | None = None
date_publication: date | None = None
tier: Tier = Tier.T2
extrait_verbatim: str | None = None
contexte: str | None = None
confiance: Confiance | None = None
marqueur: str | None = None
fichier_origine: str | None = None
@field_validator("url")
@classmethod
def _url_plausible(cls, v: str) -> str:
v = v.strip().rstrip(".,;)")
if not v.startswith(("http://", "https://")):
raise ValueError(f"URL non exploitable : {v!r}")
return v
@model_validator(mode="after")
def _deduire_tier(self) -> Source:
"""Classe automatiquement en T1 les hôtes institutionnels connus."""
if self.tier == Tier.T2 and est_source_primaire(self.url):
object.__setattr__(self, "tier", Tier.T1)
return self
HOTES_PRIMAIRES = (
"legifrance.gouv.fr",
"assemblee-nationale.fr",
"senat.fr",
"conseil-constitutionnel.fr",
"conseil-etat.fr",
"vie-publique.fr",
"journal-officiel.gouv.fr",
"elysee.fr",
"gouvernement.fr",
"defenseurdesdroits.fr",
"cnil.fr",
"insee.fr",
"data.gouv.fr",
)
def est_source_primaire(url: str) -> bool:
"""Vrai pour les hôtes institutionnels et les sous-domaines en `.gouv.fr`."""
hote = url.split("//", 1)[-1].split("/", 1)[0].lower().removeprefix("www.")
if any(hote == h or hote.endswith("." + h) for h in HOTES_PRIMAIRES):
return True
return hote.endswith(".gouv.fr")
class Evenement(BaseModel):
"""Une étape datée du parcours d'un texte."""
model_config = ConfigDict(extra="forbid")
date_evenement: date
type_etape: TypeEtape
description: str
source_url: str | None = None
previsionnel: bool = False
class DecisionCC(BaseModel):
"""Affaire portée devant le Conseil constitutionnel."""
model_config = ConfigDict(extra="forbid")
numero_affaire: str
date_saisine: date | None = None
date_decision: date | None = None
resultat: ResultatCC | None = None
saisissants: str | None = None
resume: str | None = None
url: str | None = None
date_decision_attendue: date | None = None
@field_validator("numero_affaire")
@classmethod
def _format_affaire(cls, v: str) -> str:
v = " ".join(v.split()).upper().replace("DC.", "DC")
if not MOTIF_AFFAIRE_CC.match(v):
raise ValueError(f"Numéro d'affaire attendu au format « 2026-915 DC », reçu {v!r}")
return v
@model_validator(mode="after")
def _coherence_instance(self) -> DecisionCC:
if self.date_decision is None and self.resultat is None:
object.__setattr__(self, "resultat", ResultatCC.EN_INSTANCE)
return self
class Texte(BaseModel):
"""Un texte législatif suivi par la veille."""
model_config = ConfigDict(extra="forbid")
id: str
numero_officiel: str | None = None
type: TypeTexte
titre_court: str
titre_officiel: str | None = None
statut: Statut
statut_date: date | None = None
date_depot: date | None = None
date_adoption: date | None = None
date_promulgation: date | None = None
date_entree_vigueur: date | None = None
prochaine_echeance: date | None = None
prochaine_echeance_label: str | None = None
themes: list[Theme] = Field(default_factory=list)
impacts: dict[Public, Impact] = Field(default_factory=dict)
guadeloupe_pertinence: Pertinence | None = None
guadeloupe_note: str | None = None
resume: str | None = None
points_cles: list[str] = Field(default_factory=list)
confiance: Confiance = Confiance.MEDIUM
source_seed: str | None = None
a_verifier: bool = False
motif_verification: str | None = None
sources: list[Source] = Field(default_factory=list)
evenements: list[Evenement] = Field(default_factory=list)
decisions_cc: list[DecisionCC] = Field(default_factory=list)
@field_validator("id")
@classmethod
def _slug_valide(cls, v: str) -> str:
if not MOTIF_SLUG.match(v):
raise ValueError(f"Identifiant non conforme au format slug : {v!r}")
return v
@field_validator("numero_officiel")
@classmethod
def _numero_valide(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
if not MOTIF_NUMERO.match(v):
raise ValueError(f"Numéro officiel attendu au format « 2026-491 », reçu {v!r}")
return v
@field_validator("themes")
@classmethod
def _themes_uniques(cls, v: list[Theme]) -> list[Theme]:
vus: list[Theme] = []
for t in v:
if t not in vus:
vus.append(t)
return vus
@model_validator(mode="after")
def _coherence_statut(self) -> Texte:
"""Un texte promulgué porte forcément un numéro et une date de promulgation."""
if self.statut is Statut.PROMULGUEE:
if not self.numero_officiel:
raise ValueError(f"{self.id} : statut « promulguee » sans numéro officiel")
if not self.date_promulgation:
raise ValueError(f"{self.id} : statut « promulguee » sans date de promulgation")
elif self.statut in (
Statut.ADOPTEE_NON_PROMULGUEE,
Statut.NAVETTE,
Statut.DEPOSEE_NON_EXAMINEE,
Statut.ANNONCEE,
) and self.numero_officiel:
raise ValueError(
f"{self.id} : un texte au statut « {self.statut} » ne peut pas porter "
f"le numéro officiel {self.numero_officiel!r} — un numéro n'est attribué "
"qu'à la promulgation"
)
if self.statut_date is None:
object.__setattr__(
self,
"statut_date",
self.date_promulgation or self.date_adoption or self.date_depot,
)
return self
# ── Sérialisation vers SQLite ────────────────────────────────────────────
def en_ligne_sql(self) -> dict[str, Any]:
"""Aplatit le modèle en un dictionnaire directement insérable."""
def iso(d: date | None) -> str | None:
return d.isoformat() if d else None
return {
"id": self.id,
"numero_officiel": self.numero_officiel,
"type": str(self.type),
"titre_court": self.titre_court,
"titre_officiel": self.titre_officiel,
"statut": str(self.statut),
"statut_date": iso(self.statut_date),
"date_depot": iso(self.date_depot),
"date_adoption": iso(self.date_adoption),
"date_promulgation": iso(self.date_promulgation),
"date_entree_vigueur": iso(self.date_entree_vigueur),
"prochaine_echeance": iso(self.prochaine_echeance),
"prochaine_echeance_label": self.prochaine_echeance_label,
"themes": json.dumps([str(t) for t in self.themes], ensure_ascii=False),
"impacts": json.dumps(
{str(p): {"sens": str(i.sens), "note": i.note} for p, i in self.impacts.items()},
ensure_ascii=False,
),
"guadeloupe_pertinence": (
str(self.guadeloupe_pertinence) if self.guadeloupe_pertinence else None
),
"guadeloupe_note": self.guadeloupe_note,
"resume": self.resume,
"points_cles": json.dumps(self.points_cles, ensure_ascii=False),
"confiance": str(self.confiance),
"source_seed": self.source_seed,
"a_verifier": int(self.a_verifier),
"motif_verification": self.motif_verification,
}
class Insight(BaseModel):
"""Analyse transversale exposée dans l'encart « point de veille »."""
model_config = ConfigDict(extra="forbid")
numero: int
titre: str
corps: str
implications: str | None = None
confiance: str = "medium"
derive_de: list[str] = Field(default_factory=list)
fichier_origine: str | None = None