From 29424afdd537bbc3c2179f64a77e2a9abf97762e Mon Sep 17 00:00:00 2001 From: Cyber Mawonaj Date: Sat, 25 Jul 2026 22:41:18 -0400 Subject: [PATCH] =?UTF-8?q?Int=C3=A9gration=20des=207=20textes=20d=C3=A9te?= =?UTF-8?q?ct=C3=A9s=20par=20le=20pipeline=20=E2=80=94=20la=20veille=20con?= =?UTF-8?q?verge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make update écrivait jusqu'ici les changements de statut et de date, mais se contentait de journaliser les ajouts et les décisions du Conseil : les mêmes écarts seraient revenus à chaque passage, indéfiniment. Un rapport qui répète les mêmes lignes cesse d'être lu. Deux manques comblés : - un écart d'ajout porte désormais la collecte qui l'a produit, et appliquer() crée réellement le texte via classer_nouveau() - un écart de décision porte l'affaire collectée, et l'écrit dans decisions_cc Une affaire orpheline est rattachée par similarité de titre : le registre du Conseil donne l'intitulé complet (« Loi actualisant la programmation militaire pour les années 2024 à 2030… »), la base le nom d'usage. L'affaire 2026-907 DC a ainsi trouvé sa loi toute seule. Résultat sur les sources réelles : passage 1 → 7 ajouts, 4 modifications passage 2 → 0 ajout, 4 modifications (décisions désormais écrites) passage 3 → 0 écart : « la base est conforme aux sources officielles » Les 7 textes ajoutés sont des lois de janvier à avril 2026, hors de la fenêtre du corpus mais relevées par la liste du Sénat qui couvre toute la session. Dont la loi de finances pour 2026 (n° 2026-103), qui touche l'octroi de mer et étend le versement mobilité aux DROM — donc pertinente pour la Guadeloupe. Toutes entrent avec confiance = low et le drapeau de revue : le pipeline constate qu'un texte existe, il ne prétend pas l'avoir analysé. Base : 59 textes, 367 sources, 13 affaires CC, 0 texte sans lien. 176 tests, make verifier au vert. Co-Authored-By: Claude Opus 5 --- README.md | 19 +++++--- pipeline/update.py | 64 +++++++++++++++++++++--- tests/test_update.py | 114 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a942bf4..f208579 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,23 @@ entreprises**, avec un zoom Guadeloupe et outre-mer. | | | |---|---| -| Textes législatifs | **52** — 27 promulgués, 7 devant le Conseil constitutionnel, 10 en navette, 5 déposés, 2 annoncés, 1 validé | -| Sources vérifiables | **360**, toutes avec adresse — aucun texte sans lien | -| Événements de parcours | 83 | -| Affaires du Conseil constitutionnel | 12 | +| Textes législatifs | **59** — 52 issus du corpus de recherche, 7 ajoutés par le pipeline depuis les sources officielles | +| Sources vérifiables | **367**, toutes avec adresse — aucun texte sans lien | +| Événements de parcours | 90 | +| Affaires du Conseil constitutionnel | 13 | | Analyses transversales | 7 | | Échéances de calendrier | 9 | | Marqueurs de citation résolus | **100 %** (332/332), dont 93 % avec un lien | -Les valeurs déduites — 16 cotations Guadeloupe, 17 ventilations d'impact — sont -signalées comme telles, avec leur motif. +Les valeurs déduites sont signalées comme telles, avec leur motif : 16 cotations +Guadeloupe, 17 ventilations d'impact, et les 7 textes ajoutés par le pipeline — +qui entrent tous avec la confiance la plus basse et le drapeau de revue. + +Les 52 textes du corpus couvrent la fenêtre du 1ᵉʳ mai au 30 septembre 2026. Les +7 ajouts sont antérieurs (janvier à avril 2026) : le pipeline les a relevés sur +la liste du Sénat, qui couvre toute la session. Parmi eux, la loi de finances +pour 2026 (n° 2026-103), qui modifie l'octroi de mer et étend le versement +mobilité aux départements d'outre-mer. --- diff --git a/pipeline/update.py b/pipeline/update.py index 7ea4c2d..b6da1be 100644 --- a/pipeline/update.py +++ b/pipeline/update.py @@ -51,6 +51,13 @@ class Ecart: nouvelle_valeur: str | None = None description: str = "" collecteur: str = "" + # Renseignée pour les ajouts : c'est elle qui permet de créer le texte au + # moment d'appliquer, sans relancer une collecte. + collecte: TexteCollecte | None = None + # Idem pour les affaires du Conseil constitutionnel. Sans elle, un écart de + # décision serait journalisé mais jamais écrit — et redétecté à chaque + # passage, indéfiniment. + decision: DecisionCC | None = None def en_ligne(self) -> str: cible = self.texte_id or "(nouveau)" @@ -164,7 +171,9 @@ def comparer(cx: sqlite3.Connection, collectes: list[ResultatCollecte]) -> Rappo for texte in collecte.textes: _comparer_texte(texte, candidats, connus, rapport, collecte.collecteur) for decision in collecte.decisions_cc: - _comparer_decision(decision, affaires_connues, rapport, collecte.collecteur) + _comparer_decision( + decision, affaires_connues, rapport, collecte.collecteur, candidats + ) return rapport @@ -187,6 +196,7 @@ def _comparer_texte( nature="ajout", description=f"{collecte.numero_officiel or '—'} · {collecte.titre[:110]}", collecteur=collecteur, + collecte=collecte, ) ) return @@ -245,19 +255,33 @@ def _comparer_decision( connues: dict[str, sqlite3.Row], rapport: RapportRun, collecteur: str, + candidats: list[tuple[str, str, str | None]] | None = None, ) -> None: existante = connues.get(decision.numero_affaire) if existante is None: + # Le registre du Conseil donne l'intitulé complet de la loi déférée : + # on tente de la rattacher à un texte suivi plutôt que de laisser + # l'affaire orpheline. + rattachement = None + if candidats and decision.resume: + correspondance = rapprocher( + titre=decision.resume, numero_officiel=None, candidats=candidats + ) + if correspondance.issue is Issue.RAPPROCHE: + rattachement = correspondance.texte_id + rapport.ecarts.append( Ecart( - texte_id=None, + texte_id=rattachement, nature="decision_cc", description=( f"affaire inconnue en base : {decision.numero_affaire} — " f"{(decision.resume or '')[:90]}" + + (f" → rattachée à {rattachement}" if rattachement else "") ), collecteur=collecteur, + decision=decision, ) ) return @@ -271,6 +295,7 @@ def _comparer_decision( ancienne_valeur=None, nouvelle_valeur=decision.date_decision.isoformat(), collecteur=collecteur, + decision=decision, ) ) @@ -283,6 +308,7 @@ def _comparer_decision( ancienne_valeur=None, nouvelle_valeur=decision.date_saisine.isoformat(), collecteur=collecteur, + decision=decision, ) ) @@ -299,6 +325,7 @@ def _comparer_decision( ancienne_valeur=existante["resultat"], nouvelle_valeur=str(decision.resultat), collecteur=collecteur, + decision=decision, ) ) @@ -306,9 +333,23 @@ def _comparer_decision( # ───────────────────────────────────────────────────────────────────────────── # Application # ───────────────────────────────────────────────────────────────────────────── -def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> None: - """Écrit les écarts en base. Jamais appelé en mode `--dry-run`.""" +def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> int: + """Écrit les écarts en base. Jamais appelé en mode `--dry-run`. + + Retourne le nombre de textes réellement créés. Les textes ajoutés par cette + voie entrent avec la confiance la plus basse et le drapeau de revue : la + machine constate qu'un texte existe, elle ne prétend pas l'avoir analysé. + """ + crees = 0 + with db.transaction(cx): + for ecart in rapport.ecarts: + if ecart.nature == "ajout" and ecart.collecte is not None: + texte = classer_nouveau(ecart.collecte) + db.enregistrer_texte(cx, texte) + ecart.texte_id = texte.id + crees += 1 + for ecart in rapport.ecarts: db.enregistrer_changement( cx, @@ -321,6 +362,10 @@ def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> None: description=ecart.description or None, ) + if ecart.nature == "decision_cc" and ecart.decision is not None: + db.enregistrer_decision_cc(cx, ecart.decision, texte_id=ecart.texte_id) + continue + if ecart.texte_id and ecart.champ and ecart.nature in ("statut", "date"): cx.execute( f"UPDATE textes SET {ecart.champ} = ?, maj_le = datetime('now'), " # noqa: S608 @@ -333,6 +378,8 @@ def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> None: "WHERE numero_officiel IS NOT NULL" ) + return crees + _NATURES_SQL = {"ajout", "statut", "date", "source", "autre"} @@ -470,9 +517,14 @@ def main() -> None: run_id = db.ouvrir_run(cx, mode) if not arguments.dry_run: - appliquer(cx, rapport, run_id) + crees = appliquer(cx, rapport, run_id) exportes = exporter_json(cx) - log.info("dump statique régénéré", textes=exportes, fichier=str(chemins.DUMP_JSON)) + log.info( + "écarts appliqués", + textes_crees=crees, + dump=str(chemins.DUMP_JSON), + textes_exportes=exportes, + ) db.cloturer_run( cx, diff --git a/tests/test_update.py b/tests/test_update.py index 2590cda..1b6d0e8 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -364,3 +364,117 @@ def test_la_negation_ne_porte_que_sur_son_voisinage() -> None: + "Le dispositif est sans objet pour les collectivités du Pacifique." ) assert cotation.pertinence is Pertinence.FORTE + + +# ───────────────────────────────────────────────────────────────────────────── +# Application effective : créer, mettre à jour, converger +# ───────────────────────────────────────────────────────────────────────────── + + +def test_un_ajout_cree_reellement_le_texte(base) -> None: + """Un écart d'ajout doit écrire le texte, pas seulement le journaliser.""" + collecte = _collecte( + textes=[ + TexteCollecte( + titre="LOI n° 2026-103 du 19 février 2026 de finances pour 2026", + source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999", + collecteur="senat", + numero_officiel="2026-103", + statut=Statut.PROMULGUEE, + date_promulgation=date(2026, 2, 19), + sources=[ + Source( + url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999", + editeur="Légifrance", + ) + ], + ) + ] + ) + rapport = comparer(base, [collecte]) + crees = appliquer(base, rapport, db.ouvrir_run(base, "update")) + + assert crees == 1 + ligne = base.execute("SELECT * FROM textes WHERE id = 'loi-2026-103'").fetchone() + assert ligne is not None + assert ligne["confiance"] == "low" + assert ligne["a_verifier"] == 1 + assert ligne["source_seed"] == "collecteur:senat" + # Un texte sans source serait un texte invérifiable : la collecte doit + # transmettre la sienne. + assert base.execute( + "SELECT COUNT(*) FROM sources WHERE texte_id = 'loi-2026-103'" + ).fetchone()[0] >= 1 + + +def test_une_decision_collectee_est_ecrite_pas_seulement_journalisee(base) -> None: + """Sans écriture, le même écart serait redétecté à chaque passage.""" + collecte = _collecte( + decisions_cc=[ + DecisionCC( + numero_affaire="2026-915 DC", + date_decision=date(2026, 8, 24), + resultat=ResultatCC.NON_CONFORMITE_PARTIELLE, + ) + ] + ) + rapport = comparer(base, [collecte]) + appliquer(base, rapport, db.ouvrir_run(base, "update")) + + ligne = base.execute( + "SELECT * FROM decisions_cc WHERE numero_affaire = '2026-915 DC'" + ).fetchone() + assert ligne["date_decision"] == "2026-08-24" + assert ligne["resultat"] == "non_conformite_partielle" + + +def test_le_pipeline_converge(base) -> None: + """Rejouer la même collecte après application ne doit plus rien signaler. + + C'est la propriété qui rend une veille utilisable : si le rapport répétait + les mêmes écarts à chaque passage, il deviendrait illisible et on cesserait + de le lire. + """ + collecte = _collecte( + textes=[ + TexteCollecte( + titre="LOI n° 2026-103 du 19 février 2026 de finances pour 2026", + source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000999", + collecteur="senat", + numero_officiel="2026-103", + statut=Statut.PROMULGUEE, + date_promulgation=date(2026, 2, 19), + ) + ], + decisions_cc=[ + DecisionCC( + numero_affaire="2026-915 DC", + date_decision=date(2026, 8, 24), + resultat=ResultatCC.NON_CONFORMITE_PARTIELLE, + ) + ], + ) + + premier = comparer(base, [collecte]) + assert premier.ecarts, "le premier passage doit voir des écarts" + appliquer(base, premier, db.ouvrir_run(base, "update")) + + second = comparer(base, [collecte]) + assert second.ecarts == [], "le second passage ne doit plus rien signaler" + + +def test_une_affaire_orpheline_est_rattachee_par_son_intitule(base) -> None: + """Cas réel : le Conseil donne l'intitulé complet, la base le nom d'usage.""" + collecte = _collecte( + decisions_cc=[ + DecisionCC( + numero_affaire="2026-916 DC", + date_saisine=date(2026, 8, 1), + resume="Loi « Riposte » ordre public sécurité tranquillité", + ) + ] + ) + rapport = comparer(base, [collecte]) + (ecart,) = [e for e in rapport.ecarts if e.nature == "decision_cc"] + assert ecart.texte_id == "loi-riposte" + assert "rattachée à loi-riposte" in ecart.description