Files
veye-lalwa/pipeline/seed_from_research.py
T

295 lines
11 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
from pipeline.journal import configurer, logger
from pipeline.modeles import Source, Texte
from pipeline.parseurs import (
analyses,
bibliographie,
chapitres,
citations,
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
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)
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}",
"",
"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)}",
]
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)
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. É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)
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"]
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 _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()