Phase 3 : sources, citations et seed complet — 368 sources, 0 texte orphelin

Parseurs :
- blocs_claim : les quatre dialectes des rapports de dimension. Le cahier des
  charges en postulait un seul ; sans les quatre, dim05 à dim12 étaient perdus,
  soit les dimensions associations, entreprises, migration et calendrier.
- notes_bas_page : 258 notes, dont 104 sans URL. Les identifiants JORFTEXT
  donnent lieu à une URL canonique reconstruite (schéma fixe, rien d'inventé) ;
  les autres restent sans lien plutôt que d'être devinées.
- bibliographie : 481 références, toutes liées. Indexées par numéro de loi,
  par affaire DC et par cote de document parlementaire — cette dernière étant
  la seule identité d'un texte déposé et non examiné.
- chapitres : rattachement phrase par phrase, résumés repris tels quels.

Résolution des citations : 100 % des 332 marqueurs du rapport, 93 % avec un
lien vérifiable. Il a fallu six conventions de numérotation, une par famille de
dimension (marqueur de fin, en-tête de claim, section, rang, note, section.rang).

Trois pièges corrigés, tous couverts par des tests :
- « décision n° 2026-903 DC » lu comme une loi, ce qui volait à la loi 2026-403
  les phrases — et donc les sources — qui parlaient d'elle
- retrait des marqueurs laissant « … dans les DROM . » ; corrigé à la source
  sans toucher aux espaces avant « ; » et « » » qu'exige le français
- « programmation militaire 2024-2030 » lu comme la loi n° 2024-2030

make seed : 52 textes, 368 sources, 83 événements, 12 affaires CC, 7 analyses.
Zéro texte sans source URL (critère §8.2). Les 2 textes sans résumé et les 17
pertinences déduites sont marqués « à vérifier » avec leur motif.

99 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyber Mawonaj
2026-07-25 19:43:03 -04:00
co-authored by Claude Opus 5
parent c36b8dc631
commit c28243cf87
12 changed files with 2108 additions and 3 deletions
+294
View File
@@ -0,0 +1,294 @@
"""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()