"""Remplissage initial de la base à partir du corpus de recherche. Enchaînement : 1. **Annexe** (`sec10.md`) — 52 textes avec statut, dates, cotation des libertés et pertinence Guadeloupe pour les lois promulguées. 2. **Citations** — index de résolution des marqueurs `[^dimXX-N^]` vers les sources vérifiables des douze rapports de dimension. 3. **Chapitres** (`sec00` → `sec09`) — résumés, points clés et marqueurs supplémentaires, rattachés phrase par phrase. 4. **Bibliographie** — filet de sécurité : toute loi promulguée reçoit les références consolidées qui citent son numéro. 5. **Analyses** (`insight.md`) — sept lectures transversales. Rien n'est écrit qui ne vienne d'un fichier de `data/input/`, lequel n'est jamais modifié. Le compte-rendu d'import indique fichier par fichier ce qui a été lu, ce qui a été rattaché et ce qui manque. """ from __future__ import annotations import argparse import sqlite3 import time from dataclasses import dataclass, field from pipeline import chemins, db, guadeloupe, impacts, resolution_conflits from pipeline.journal import configurer, logger from pipeline.modeles import Source, Texte from pipeline.parseurs import ( analyses, bibliographie, chapitres, citations, echeances, tableaux_sec10, ) log = logger("seed") # Plancher fixé au §2 du cahier des charges. TEXTES_ATTENDUS_MINIMUM = 49 @dataclass class CompteRendu: """Ce que le seed a lu, écrit et manqué.""" textes: int = 0 sources: int = 0 evenements: int = 0 decisions_cc: int = 0 analyses: int = 0 echeances: int = 0 textes_sans_source: list[str] = field(default_factory=list) textes_sans_resume: list[str] = field(default_factory=list) pertinences_deduites: list[str] = field(default_factory=list) impacts_deduits: list[str] = field(default_factory=list) verdicts_appliques: int = 0 avertissements: list[str] = field(default_factory=list) marqueurs_avec_lien: int = 0 marqueurs_sans_lien: int = 0 marqueurs_non_resolus: int = 0 def en_texte(self) -> str: lignes = [ "Compte-rendu d'import du corpus de recherche", "=" * 44, f" textes : {self.textes}", f" sources : {self.sources}", f" événements : {self.evenements}", f" décisions CC : {self.decisions_cc}", f" analyses : {self.analyses}", f" échéances : {self.echeances}", "", "Résolution des citations", f" avec lien : {self.marqueurs_avec_lien}", f" sans lien (réf.) : {self.marqueurs_sans_lien}", f" non résolus : {self.marqueurs_non_resolus}", "", "Points d'attention", f" textes sans source URL : {len(self.textes_sans_source)}" + (f" → {', '.join(self.textes_sans_source)}" if self.textes_sans_source else ""), f" textes sans résumé : {len(self.textes_sans_resume)}" + (f" → {', '.join(self.textes_sans_resume)}" if self.textes_sans_resume else ""), f" pertinences déduites : {len(self.pertinences_deduites)}", f" impacts déduits : {len(self.impacts_deduits)}", f" arbitrages appliqués : {self.verdicts_appliques}", ] if self.avertissements: lignes.append("") lignes.append("Avertissements") lignes.extend(f" ! {a}" for a in self.avertissements) return "\n".join(lignes) def ensemencer(cx: sqlite3.Connection) -> CompteRendu: """Construit le jeu de données initial et l'écrit en base.""" compte = CompteRendu() manquants = chemins.verifier_corpus() if manquants: compte.avertissements.extend(f"fichier d'entrée absent : {m}" for m in manquants) # 1. Annexe récapitulative — le socle factuel. annexe = tableaux_sec10.parser(chemins.chapitre(10).read_text(encoding="utf-8")) compte.avertissements.extend(annexe.avertissements) textes: dict[str, Texte] = {texte.id: texte for texte in annexe.textes} marqueurs_annexe = annexe.marqueurs # 2. Index de résolution des citations. repertoire = citations.construire() # 3. Chapitres rédigés. lecture = chapitres.parser() # 4. Enrichissement texte par texte. for texte in textes.values(): _rattacher_sources(texte, repertoire, lecture, marqueurs_annexe) _rattacher_redaction(texte, lecture) _coter_guadeloupe(texte, compte) _ventiler_impacts(texte, lecture, compte) if not texte.sources: compte.textes_sans_source.append(texte.id) if not texte.resume: compte.textes_sans_resume.append(texte.id) texte.a_verifier = True texte.motif_verification = _ajouter_motif( texte.motif_verification, "Aucun paragraphe du rapport ne développe ce texte : " "seul l'essentiel de l'annexe est disponible.", ) compte.marqueurs_avec_lien = len(repertoire.resolus_avec_lien) compte.marqueurs_sans_lien = len(repertoire.resolus_sans_lien) compte.marqueurs_non_resolus = len(repertoire.non_resolus) # 5. Arbitrages de la validation de phase 5 — le §7 du cahier des charges # exige que la loi « Riposte » porte l'AFD à 500 € et non à 1 500 €. verdicts = resolution_conflits.lire_verdicts( chemins.PHASE5_VALIDATION.read_text(encoding="utf-8") ) compte.verdicts_appliques = resolution_conflits.appliquer(textes, verdicts) # 6. Écriture. db.enregistrer_textes(cx, textes.values()) lectures = analyses.parser(chemins.INSIGHTS.read_text(encoding="utf-8")) with db.transaction(cx): for analyse in lectures: db.enregistrer_insight(cx, analyse) for echeance in echeances.echeances_nationales(): db.enregistrer_echeance(cx, echeance) statistiques = db.statistiques(cx) compte.textes = statistiques["textes"] compte.sources = statistiques["sources"] compte.evenements = statistiques["evenements"] compte.decisions_cc = statistiques["decisions_cc"] compte.analyses = statistiques["insights"] compte.echeances = statistiques["echeances"] if compte.textes < TEXTES_ATTENDUS_MINIMUM: compte.avertissements.append( f"{compte.textes} textes importés, {TEXTES_ATTENDUS_MINIMUM} attendus au minimum" ) return compte # ───────────────────────────────────────────────────────────────────────────── # Enrichissement # ───────────────────────────────────────────────────────────────────────────── def _rattacher_sources( texte: Texte, repertoire: citations.Repertoire, lecture: chapitres.LectureChapitres, marqueurs_annexe: dict[str, list[str]], ) -> None: """Attache à un texte toutes les sources que le corpus lui rattache.""" # Marqueurs portés par sa ligne d'annexe, puis par les chapitres. marqueurs = list(marqueurs_annexe.get(texte.id, [])) marqueurs.extend(lecture.marqueurs_de(texte.id)) sources = repertoire.resoudre_plusieurs(marqueurs) # Filet : la bibliographie consolidée. Trois clés d'accroche, du plus # spécifique au moins spécifique — un texte non promulgué n'a pas de numéro # de loi, sa seule identité est sa cote parlementaire (« AN n° 3025 »). affaires = [decision.numero_affaire for decision in texte.decisions_cc] documents = bibliographie.numeros_de_document( f"{texte.titre_court} {texte.titre_officiel or ''}" ) sources.extend( repertoire.references_du_texte( numero_loi=texte.numero_officiel, affaires=affaires, documents=documents ) ) vues: set[str] = set() retenues: list[Source] = [] for source in sources: if source.url in vues: continue vues.add(source.url) retenues.append(source) texte.sources = retenues def _rattacher_redaction(texte: Texte, lecture: chapitres.LectureChapitres) -> None: """Reprend résumé et points clés du rapport, sans les reformuler.""" if resume := lecture.resume_de(texte.id): texte.resume = resume # Les points clés de l'annexe (colonne « Enjeu libertés ») sont déjà posés # par le parseur de tableaux ; on complète avec la note de régime. if texte.regime_libertes_note and texte.regime_libertes_note not in texte.points_cles: texte.points_cles.append(texte.regime_libertes_note) if texte.guadeloupe_note and texte.guadeloupe_note not in texte.points_cles: texte.points_cles.append(texte.guadeloupe_note) def _coter_guadeloupe(texte: Texte, compte: CompteRendu) -> None: """Cote la pertinence guadeloupéenne des textes que l'annexe ne cote pas. Les 27 lois promulguées portent la cotation manuelle du rapport : elle est conservée telle quelle, conformément au §5.3 du cahier des charges. """ if texte.guadeloupe_pertinence is not None: return cotation = guadeloupe.coter( texte.titre_court, texte.titre_officiel, texte.resume, " ".join(texte.points_cles), texte.guadeloupe_note, ) if cotation.pertinence is None: return texte.guadeloupe_pertinence = cotation.pertinence texte.guadeloupe_note = texte.guadeloupe_note or cotation.note texte.a_verifier = True texte.motif_verification = _ajouter_motif( texte.motif_verification, guadeloupe.MOTIF_VERIFICATION ) compte.pertinences_deduites.append(texte.id) def _ventiler_impacts( texte: Texte, lecture: chapitres.LectureChapitres, compte: CompteRendu ) -> None: """Répartit l'effet du texte entre les trois publics suivis.""" ventilation = impacts.ventiler(texte.id, lecture, regime=texte.regime_libertes) if not ventilation.impacts: return texte.impacts = ventilation.impacts if ventilation.deduit: texte.a_verifier = True texte.motif_verification = _ajouter_motif( texte.motif_verification, impacts.MOTIF_VERIFICATION ) compte.impacts_deduits.append(texte.id) def _ajouter_motif(existant: str | None, nouveau: str) -> str: if not existant: return nouveau if nouveau in existant: return existant return f"{existant} {nouveau}" # ───────────────────────────────────────────────────────────────────────────── # Ligne de commande # ───────────────────────────────────────────────────────────────────────────── def main() -> None: analyseur = argparse.ArgumentParser( description="Remplit la base à partir du corpus de recherche de data/input/." ) analyseur.add_argument( "--repartir-de-zero", action="store_true", help="supprime la base existante avant l'import", ) analyseur.add_argument("--verbeux", action="store_true", help="journalisation détaillée") arguments = analyseur.parse_args() configurer("DEBUG" if arguments.verbeux else "INFO") depart = time.monotonic() cx = db.initialiser(repartir_de_zero=arguments.repartir_de_zero) run_id = db.ouvrir_run(cx, "seed") compte = ensemencer(cx) duree = time.monotonic() - depart db.cloturer_run( cx, run_id, duree_s=duree, ajouts=compte.textes, echecs=len(compte.avertissements), alertes=compte.avertissements, rapport=compte.en_texte(), ) print() print(compte.en_texte()) print() log.info( "seed terminé", duree_s=round(duree, 2), textes=compte.textes, sources=compte.sources, base=str(chemins.BASE_SQLITE), ) if compte.textes < TEXTES_ATTENDUS_MINIMUM: raise SystemExit(1) if __name__ == "__main__": main()