Files
veye-lalwa/pipeline/seed_from_research.py
T
Cyber MawonajandClaude Opus 5 f3e546cfc4 Phase 4 : impacts par public, arbitrages, calendrier — les 7 critères §7 passent
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>
2026-07-25 20:23:51 -04:00

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