From 31d3935bce25f3df79f51922a615a23de1056835 Mon Sep 17 00:00:00 2001 From: Cyber Mawonaj Date: Sat, 25 Jul 2026 17:49:11 -0400 Subject: [PATCH] =?UTF-8?q?Phase=201=20:=20socle=20Python,=20sch=C3=A9ma?= =?UTF-8?q?=20SQLite=20+=20FTS5,=20mod=C3=A8les=20pydantic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Makefile | 84 ++++++ README.md | 34 +++ pipeline/__init__.py | 8 + pipeline/chemins.py | 66 +++++ pipeline/db.py | 381 ++++++++++++++++++++++++++++ pipeline/journal.py | 39 +++ pipeline/migrations/001_initial.sql | 276 ++++++++++++++++++++ pipeline/modeles.py | 380 +++++++++++++++++++++++++++ pyproject.toml | 48 ++++ 9 files changed, 1316 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 pipeline/__init__.py create mode 100644 pipeline/chemins.py create mode 100644 pipeline/db.py create mode 100644 pipeline/journal.py create mode 100644 pipeline/migrations/001_initial.sql create mode 100644 pipeline/modeles.py create mode 100644 pyproject.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0073312 --- /dev/null +++ b/Makefile @@ -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 {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c4143d --- /dev/null +++ b/README.md @@ -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. diff --git a/pipeline/__init__.py b/pipeline/__init__.py new file mode 100644 index 0000000..b1357d4 --- /dev/null +++ b/pipeline/__init__.py @@ -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" diff --git a/pipeline/chemins.py b/pipeline/chemins.py new file mode 100644 index 0000000..3f4353a --- /dev/null +++ b/pipeline/chemins.py @@ -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()] diff --git a/pipeline/db.py b/pipeline/db.py new file mode 100644 index 0000000..3d5e644 --- /dev/null +++ b/pipeline/db.py @@ -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() diff --git a/pipeline/journal.py b/pipeline/journal.py new file mode 100644 index 0000000..0532dde --- /dev/null +++ b/pipeline/journal.py @@ -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) diff --git a/pipeline/migrations/001_initial.sql b/pipeline/migrations/001_initial.sql new file mode 100644 index 0000000..f9f0ca7 --- /dev/null +++ b/pipeline/migrations/001_initial.sql @@ -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; diff --git a/pipeline/modeles.py b/pipeline/modeles.py new file mode 100644 index 0000000..26a36ad --- /dev/null +++ b/pipeline/modeles.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bde1965 --- /dev/null +++ b/pyproject.toml @@ -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"]