"""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 RegimeLibertes(StrEnum): """Cotation globale d'un texte par l'annexe du rapport (`sec10.md`).""" DROITS_PLUS = "droits_plus" # création ou extension de droits CONTROLE_PLUS = "controle_plus" # restriction, ou extension des contrôles MIXTE = "mixte" NEUTRE = "neutre" 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) regime_libertes: RegimeLibertes | None = None regime_libertes_note: str | None = None 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, ), "regime_libertes": str(self.regime_libertes) if self.regime_libertes else None, "regime_libertes_note": self.regime_libertes_note, "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