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:
co-authored by
Claude Opus 5
parent
3ce7d17db9
commit
31d3935bce
@@ -0,0 +1,84 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Veille législative Guadeloupe — commandes du projet
|
||||||
|
#
|
||||||
|
# make install installe le pipeline Python et l'application web
|
||||||
|
# make db-init crée ou migre la base SQLite
|
||||||
|
# make seed remplit la base depuis data/input/ (lecture seule)
|
||||||
|
# make update-dry collecte réelle sans écriture, rapport des différences
|
||||||
|
# make update collecte réelle et mise à jour de la base
|
||||||
|
# make test pytest avec couverture du pipeline
|
||||||
|
# make dev serveur de développement SvelteKit
|
||||||
|
# make build build de production
|
||||||
|
# make verifier contrôles de conformité (§3bis et §8 du cahier des charges)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PY := .venv/bin/python
|
||||||
|
PYTEST := .venv/bin/pytest
|
||||||
|
RUFF := .venv/bin/ruff
|
||||||
|
WEB := web
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := aide
|
||||||
|
.PHONY: aide install install-py install-web db-init seed reseed update update-dry \
|
||||||
|
test couverture lint format dev build check verifier propre
|
||||||
|
|
||||||
|
aide:
|
||||||
|
@grep -E '^# make' Makefile | sed 's/^# / /'
|
||||||
|
|
||||||
|
# ── Installation ─────────────────────────────────────────────────────────────
|
||||||
|
install: install-py install-web
|
||||||
|
|
||||||
|
install-py:
|
||||||
|
uv venv --python 3.12
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
|
||||||
|
install-web:
|
||||||
|
cd $(WEB) && npm ci || (cd $(WEB) && npm install)
|
||||||
|
|
||||||
|
# ── Pipeline ─────────────────────────────────────────────────────────────────
|
||||||
|
db-init:
|
||||||
|
$(PY) -m pipeline.db
|
||||||
|
|
||||||
|
seed:
|
||||||
|
$(PY) -m pipeline.seed_from_research
|
||||||
|
|
||||||
|
reseed:
|
||||||
|
$(PY) -m pipeline.seed_from_research --repartir-de-zero
|
||||||
|
|
||||||
|
update:
|
||||||
|
$(PY) -m pipeline.update
|
||||||
|
|
||||||
|
update-dry:
|
||||||
|
$(PY) -m pipeline.update --dry-run
|
||||||
|
|
||||||
|
# ── Qualité ──────────────────────────────────────────────────────────────────
|
||||||
|
test:
|
||||||
|
$(PYTEST) --cov=pipeline --cov-report=term-missing
|
||||||
|
|
||||||
|
couverture:
|
||||||
|
$(PYTEST) --cov=pipeline --cov-report=html
|
||||||
|
@echo "Rapport : htmlcov/index.html"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
$(RUFF) check pipeline tests
|
||||||
|
|
||||||
|
format:
|
||||||
|
$(RUFF) format pipeline tests
|
||||||
|
$(RUFF) check --fix pipeline tests
|
||||||
|
|
||||||
|
# ── Application web ──────────────────────────────────────────────────────────
|
||||||
|
dev:
|
||||||
|
cd $(WEB) && npm run dev
|
||||||
|
|
||||||
|
build:
|
||||||
|
cd $(WEB) && npm run build
|
||||||
|
|
||||||
|
check:
|
||||||
|
cd $(WEB) && npm run check
|
||||||
|
|
||||||
|
# ── Conformité ───────────────────────────────────────────────────────────────
|
||||||
|
verifier:
|
||||||
|
@bash scripts/verifier-conformite.sh
|
||||||
|
|
||||||
|
propre:
|
||||||
|
rm -rf .pytest_cache htmlcov .coverage $(WEB)/.svelte-kit $(WEB)/build
|
||||||
|
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Veille législative Guadeloupe
|
||||||
|
|
||||||
|
Plateforme de veille sur la production législative française — lois, projets et
|
||||||
|
propositions de loi, ordonnances, décisions du Conseil constitutionnel — analysée
|
||||||
|
sous l'angle des **libertés des individus, des associations et des entreprises**,
|
||||||
|
avec un zoom Guadeloupe et outre-mer.
|
||||||
|
|
||||||
|
Deux composantes indissociables :
|
||||||
|
|
||||||
|
1. **`pipeline/`** — automatisation Python. Ingère le corpus de recherche
|
||||||
|
(date d'arrêté : 25 juillet 2026, périmètre 1ᵉʳ mai → 30 septembre 2026) puis
|
||||||
|
met la base à jour depuis les sources officielles.
|
||||||
|
2. **`web/`** — application SvelteKit rendue côté serveur : recherche plein
|
||||||
|
texte, filtres à facettes partageables, calendrier des échéances, fiches
|
||||||
|
détaillées avec sources et extraits verbatim.
|
||||||
|
|
||||||
|
> **Aucune donnée législative n'est inventée.** Chaque fait porte sa source
|
||||||
|
> (URL, date, extrait), son niveau de confiance et son fichier d'origine. Ce qui
|
||||||
|
> n'est pas établi est marqué « à vérifier » plutôt que deviné.
|
||||||
|
|
||||||
|
## État d'avancement
|
||||||
|
|
||||||
|
| Phase | Objet | Statut |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Lecture du corpus, plan d'implémentation | ✅ voir `PLAN-IMPLEMENTATION.md` |
|
||||||
|
| 1 | Socle : schéma SQLite + FTS5, modèles | 🚧 en cours |
|
||||||
|
| 2 | Parseur des tableaux récapitulatifs | ⏳ |
|
||||||
|
| 3 | Parseurs des rapports de dimension, citations | ⏳ |
|
||||||
|
| 4 | Enrichissement et tests d'acceptation | ⏳ |
|
||||||
|
| 5 | Collecteurs officiels, run de mise à jour | ⏳ |
|
||||||
|
| 6 | Application web | ⏳ |
|
||||||
|
| 7 | PWA, systemd, documentation d'installation | ⏳ |
|
||||||
|
|
||||||
|
L'installation complète sur Debian/Ubuntu vierge est documentée en phase 7.
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[project]
|
||||||
|
name = "veille-legislative-971"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Pipeline de veille législative française — impacts sur les libertés, zoom Guadeloupe"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
|
dependencies = [
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"selectolax==0.3.27",
|
||||||
|
"pydantic==2.10.6",
|
||||||
|
"structlog==25.1.0",
|
||||||
|
"python-dotenv==1.0.1",
|
||||||
|
"pyyaml==6.0.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest==8.3.4",
|
||||||
|
"pytest-cov==6.0.0",
|
||||||
|
"ruff==0.9.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
veille-seed = "pipeline.seed_from_research:main"
|
||||||
|
veille-update = "pipeline.update:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["pipeline"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["pipeline"]
|
||||||
|
omit = ["pipeline/collecteurs/*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
Reference in New Issue
Block a user