Files
veye-lalwa/pipeline/seed_from_research.py
T

330 lines
12 KiB
Python
Raw Normal View History

"""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()