Impacts : le rapport ne dresse pas de tableau texte × public, mais il consacre un chapitre à chacun (sec04 individus, sec05 associations, sec06 entreprises). C'est ce placement éditorial — un choix explicite de l'auteur, pas une inférence — qui sert de base à la ventilation. Le sens vient de la cotation manuelle de l'annexe quand elle existe, sinon du vocabulaire, et le texte est alors marqué « à vérifier » (17 cas). Arbitrages de la validation de phase 5 portés en points clés : loi-riposte affiche l'AFD à 500 €, et le montant de 1 500 € n'apparaît que présenté comme la version sénatoriale abandonnée. Migration 004 + calendrier : 9 échéances nationales transcrites du corpus avec leur extrait source. Celle des sénatoriales porte explicitement concerne_guadeloupe = 0 — série 1, renouvelée en 2023. Défaut de qualité corrigé : un titre de section nommant plusieurs textes (« Cohésion républicaine, logement, GloBE, entrisme ») faisait hériter chaque phrase de transition aux quatre, polluant leurs résumés. Une section n'est dédiée que si elle nomme un seul texte. 118 tests, 92 % de couverture. Les 7 garde-fous du §7 et les 5 démos du §8.7 sont automatisés dans tests/test_acceptation.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""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()
|