jurix-api / tests /test_scripts /test_reclassify_domains.py
rahim237cm's picture
Claude Opus 5.5
fix(reclassement): la simulation montre tout ce que --force ecrira
f508b99
Raw History Blame Contribute Delete
14.7 kB
"""
Le reclassement par lots : journal reprenable, quota, application relue.
Le classifieur est double : ces tests portent sur ce que le script fait des
verdicts — journal, reprise sans rappel, arret propre sur quota, rien
d'applique sous le seuil, rien d'ecrit en simulation.
Usage:
pytest tests/test_scripts/test_reclassify_domains.py -v
"""
import csv
import json
import pytest
from sqlalchemy import text
from app.models.law import Category, Law
from app.services.legal_domain_classifier import (
ADMINISTRATIF,
CANONICAL_DOMAINS,
FINANCES,
FONCTION_PUBLIQUE,
INTERNATIONAL,
ClassementIndisponible,
DomainResult,
ResultatLot,
)
from scripts.maintenance import reclassify_domains as script
class _Classeur:
"""Rend, dans l'ordre, les verdicts programmes (un domaine, None ou une exception)."""
modele = "doublure"
def __init__(self, *reponses):
self.reponses = list(reponses)
self.lots = []
def classer_lot(self, documents, *, attente_max=None):
self.lots.append(list(documents))
reponse = self.reponses.pop(0)
if isinstance(reponse, Exception):
raise reponse
verdicts = [
None if domaine is None else DomainResult(
domain=domaine, confidence=confiance, rule="groq:doublure", source="groq",
runners_up=((INTERNATIONAL, 0.5),),
)
for domaine, confiance in reponse
]
return ResultatLot(verdicts, jetons=300, requetes=1, modele="doublure")
@pytest.fixture
def base(sync_db_session):
"""Les 14 domaines et 5 lois publiees, toutes rangees en Administratif."""
for ordre, nom in enumerate(CANONICAL_DOMAINS):
sync_db_session.add(Category(name=nom, description=nom, display_order=ordre))
sync_db_session.commit()
ids = dict(sync_db_session.execute(text("SELECT name, id FROM categories")).all())
for i in range(1, 6):
sync_db_session.add(Law(
reference=f"R-{i}", title=f"Décret n°{i}", type="decret", content="Contenu.",
language="fr", status="published", category_id=ids[ADMINISTRATIF],
))
sync_db_session.commit()
lois = [loi.id for loi in sync_db_session.query(Law).order_by(Law.id)]
return sync_db_session, ids, lois
def _journal(chemin):
return [json.loads(ligne) for ligne in chemin.read_text(encoding="utf-8").splitlines()]
class TestClasser:
def test_ecrit_un_verdict_par_loi(self, base, tmp_path):
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
classeur = _Classeur(
[(FINANCES, 0.9), (FONCTION_PUBLIQUE, 0.8), (None, None)],
[(FINANCES, 0.4), (ADMINISTRATIF, 0.7)],
)
code = script.classer(session, classeur, journal=journal, taille_lot=3)
assert code == 0
assert [len(lot) for lot in classeur.lots] == [3, 2]
lignes = _journal(journal)
assert [ligne["law_id"] for ligne in lignes] == lois
assert lignes[0]["domaine"] == FINANCES
assert lignes[0]["secondaires"] == [INTERNATIONAL]
assert lignes[2]["statut"] == "a_revoir"
assert lignes[2]["domaine"] is None
assert {ligne["consignes"] for ligne in lignes} == {script.VERSION_DES_CONSIGNES}
# Classer n'ecrit rien en base.
assert session.query(Law).filter(Law.category_id == ids[ADMINISTRATIF]).count() == 5
def test_reprise_sans_rappel(self, base, tmp_path):
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
script.classer(
session, _Classeur([(FINANCES, 0.9)] * 5), journal=journal, taille_lot=5
)
relance = _Classeur()
code = script.classer(session, relance, journal=journal, taille_lot=5)
assert code == 0
assert relance.lots == []
def test_quota_arret_propre_code_4(self, base, tmp_path):
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
classeur = _Classeur(
[(FINANCES, 0.9), (FINANCES, 0.9)],
ClassementIndisponible("TPD", retry_after=3600, quota=True),
)
code = script.classer(session, classeur, journal=journal, taille_lot=2)
assert code == script.SORTIE_QUOTA
# Le lot deja classe est garde : la relance repart de la troisieme loi.
assert [ligne["law_id"] for ligne in _journal(journal)] == lois[:2]
relance = _Classeur([(FINANCES, 0.9)] * 3)
script.classer(session, relance, journal=journal, taille_lot=5)
assert [d.titre for d in relance.lots[0]] == ["Décret n°3", "Décret n°4", "Décret n°5"]
def test_saturation_courte_attendue_sur_place(self, base, tmp_path):
session, ids, lois = base
sommeils = []
classeur = _Classeur(
ClassementIndisponible("RPM", retry_after=20),
[(FINANCES, 0.9)] * 5,
)
code = script.classer(
session, classeur, journal=tmp_path / "v.jsonl", taille_lot=5, dormir=sommeils.append
)
assert code == 0
assert sommeils == [20]
def test_panne_code_3(self, base, tmp_path):
session, ids, lois = base
classeur = _Classeur(ClassementIndisponible("503"))
code = script.classer(session, classeur, journal=tmp_path / "v.jsonl", taille_lot=5)
assert code == script.SORTIE_PANNE
def test_incertains_seulement_avec_extrait(self, base, tmp_path):
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
session.execute(
text("INSERT INTO articles (law_id, number, content, \"order\", kind) "
"VALUES (:id, '1', 'Article 1er.- Est ratifié l''accord de prêt.', 1, 'article')"),
{"id": lois[1]},
)
session.commit()
script.classer(
session,
_Classeur([(FINANCES, 0.9), (INTERNATIONAL, 0.4), (None, None), (FINANCES, 0.95), (FINANCES, 0.9)]),
journal=journal, taille_lot=5,
)
seconde = _Classeur([(FINANCES, 0.85), (FONCTION_PUBLIQUE, 0.8)])
script.classer(
session, seconde, journal=journal, taille_lot=15,
longueur_extrait=500, incertains=True,
)
assert [d.titre for d in seconde.lots[0]] == ["Décret n°2", "Décret n°3"]
assert seconde.lots[0][0].extrait == "Article 1er.- Est ratifié l'accord de prêt."
derniers = script.charger_verdicts(journal)
assert derniers[lois[1]]["domaine"] == FINANCES
assert derniers[lois[1]]["extrait"] == 500
# Relance apres un arret : la loi 3, encore « a revoir » mais deja
# retentee avec un extrait, n'est pas repayee.
troisieme = _Classeur()
script.classer(
session, troisieme, journal=journal, taille_lot=15,
longueur_extrait=500, incertains=True,
)
assert troisieme.lots == []
def test_une_ligne_coupee_n_absorbe_pas_la_suivante(self, base, tmp_path):
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
journal.write_text('{"law_id": 999, "titre": "Déc', encoding="utf-8")
script.classer(session, _Classeur([(FINANCES, 0.9)] * 5), journal=journal, taille_lot=5)
assert set(script.charger_verdicts(journal)) == set(lois)
def test_le_verdict_d_une_autre_loi_ne_compte_pas(self, base, tmp_path):
"""Base reconstruite : l'identifiant 1 designe maintenant une autre loi."""
session, ids, lois = base
journal = tmp_path / "verdicts.jsonl"
journal.write_text(json.dumps({
"law_id": lois[0], "reference": "AUTRE-LOI", "statut": "ok",
"domaine": FINANCES, "confiance": 0.9, "consignes": script.VERSION_DES_CONSIGNES,
}) + "\n", encoding="utf-8")
classeur = _Classeur([(FINANCES, 0.9)] * 5)
script.classer(session, classeur, journal=journal, taille_lot=5)
assert len(classeur.lots[0]) == 5
assert script.charger_verdicts(journal)[lois[0]]["reference"] == "R-1"
def _verdicts(lois, *domaines):
"""{law_id: verdict} : (domaine, confiance) ou None pour « a revoir »."""
verdicts = {}
for loi, verdict in zip(lois, domaines):
domaine, confiance = verdict if verdict else (None, None)
verdicts[loi] = {
"law_id": loi, "statut": "ok" if verdict else "a_revoir", "domaine": domaine,
"secondaires": [INTERNATIONAL] if verdict else [], "confiance": confiance,
}
return verdicts
def _csv(chemin):
with chemin.open(encoding="utf-8") as fichier:
return list(csv.DictReader(fichier))
class TestAppliquer:
def test_dry_run_n_ecrit_rien_en_base(self, base, tmp_path):
session, ids, lois = base
verdicts = _verdicts(lois, (FINANCES, 0.9), (FONCTION_PUBLIQUE, 0.8))
bilan = script.appliquer(session, verdicts, dossier=tmp_path, force=True, dry_run=True)
session.expire_all()
assert bilan.changees == 2
assert session.query(Law).filter(Law.category_id == ids[ADMINISTRATIF]).count() == 5
assert [ligne["apres"] for ligne in _csv(tmp_path / "changements.csv")] == [
FINANCES, FONCTION_PUBLIQUE,
]
def test_la_simulation_sans_force_montre_les_categories_qu_il_remplacera(self, base, tmp_path):
"""
Le cas du corpus : toutes les lois ont deja une categorie. Une
simulation qui n'en listait aucune laissait relire un fichier vide
avant un --force qui les reecrivait toutes.
"""
session, ids, lois = base
verdicts = _verdicts(lois, (FINANCES, 0.9))
bilan = script.appliquer(session, verdicts, dossier=tmp_path, dry_run=True)
assert bilan.protegees == 1
lignes = _csv(tmp_path / "changements.csv")
assert [(ligne["apres"], ligne["suite"]) for ligne in lignes] == [
(FINANCES, "gardee (--force pour appliquer)"),
]
def test_l_anomalie_de_classement_est_effacee(self, base, tmp_path):
session, ids, lois = base
session.execute(text("UPDATE laws SET processing_error = :e WHERE id = :id"), {
"id": lois[0],
"e": "page 3 illisible | Classement indisponible (quota Groq) : relancer "
"scripts/maintenance/reclassify_domains.py",
})
session.execute(text("UPDATE laws SET processing_error = :e WHERE id = :id"), {
"id": lois[1], "e": "Classement indisponible (panne) : relancer ...",
})
session.commit()
script.appliquer(
session, _verdicts(lois, (FINANCES, 0.9), (FINANCES, 0.9)), dossier=tmp_path, force=True
)
session.expire_all()
assert session.get(Law, lois[0]).processing_error == "page 3 illisible"
assert session.get(Law, lois[1]).processing_error is None
def test_commit_toutes_les_n_lois_ecrites(self, base, tmp_path, monkeypatch):
"""Compter les positions sautait le commit quand la n-ieme loi etait a revoir."""
session, ids, lois = base
monkeypatch.setattr(script, "LOIS_PAR_COMMIT", 2)
commits = []
vrai_commit = session.commit
monkeypatch.setattr(session, "commit", lambda: (commits.append(1), vrai_commit()))
verdicts = _verdicts(lois, (FINANCES, 0.9), None, (FINANCES, 0.9), None, (FINANCES, 0.9))
script.appliquer(session, verdicts, dossier=tmp_path, force=True)
# 3 lois ecrites : un commit a la 2e, puis le commit final.
assert len(commits) == 2
def test_le_verdict_d_une_autre_loi_n_est_pas_applique(self, base, tmp_path):
session, ids, lois = base
verdicts = _verdicts(lois, (FINANCES, 0.9))
verdicts[lois[0]]["reference"] = "AUTRE-LOI"
bilan = script.appliquer(session, verdicts, dossier=tmp_path, force=True)
session.expire_all()
assert bilan.a_revoir == 1
assert session.get(Law, lois[0]).category_id == ids[ADMINISTRATIF]
def test_force_applique_categorie_confiance_et_suggestions(self, base, tmp_path):
session, ids, lois = base
verdicts = _verdicts(lois, (FINANCES, 0.9))
script.appliquer(session, verdicts, dossier=tmp_path, force=True)
session.expire_all()
loi = session.get(Law, lois[0])
assert loi.category_id == ids[FINANCES]
assert loi.category_confidence == pytest.approx(0.9)
assert loi.suggested_categories == [ids[FINANCES], ids[INTERNATIONAL]]
def test_sans_force_une_categorie_posee_est_gardee(self, base, tmp_path):
session, ids, lois = base
session.execute(text("UPDATE laws SET category_id = NULL WHERE id = :id"), {"id": lois[1]})
session.commit()
verdicts = _verdicts(lois, (FINANCES, 0.9), (FONCTION_PUBLIQUE, 0.9))
bilan = script.appliquer(session, verdicts, dossier=tmp_path)
session.expire_all()
assert bilan.protegees == 1
assert session.get(Law, lois[0]).category_id == ids[ADMINISTRATIF]
assert session.get(Law, lois[1]).category_id == ids[FONCTION_PUBLIQUE]
def test_a_revoir_et_sous_le_seuil_non_appliques(self, base, tmp_path):
"""Jamais d'Administratif par defaut, ni d'autre domaine invente."""
session, ids, lois = base
session.execute(text("UPDATE laws SET category_id = NULL"))
session.commit()
verdicts = _verdicts(lois, None, (FINANCES, 0.5), (FINANCES, 0.6))
bilan = script.appliquer(session, verdicts, dossier=tmp_path, seuil=0.6, force=True)
session.expire_all()
assert bilan.a_revoir == 2
assert session.get(Law, lois[0]).category_id is None
assert session.get(Law, lois[1]).category_id is None
assert session.get(Law, lois[2]).category_id == ids[FINANCES]
revoir = _csv(tmp_path / "a_revoir.csv")
assert [int(ligne["law_id"]) for ligne in revoir] == lois[:2]
assert revoir[0]["statut"] == "a_revoir"
class TestJournal:
def test_le_dernier_verdict_l_emporte_et_une_ligne_tronquee_est_ignoree(self, tmp_path):
journal = tmp_path / "verdicts.jsonl"
journal.write_text(
'{"law_id": 1, "statut": "ok", "domaine": "A"}\n'
'{"law_id": 1, "statut": "ok", "domaine": "B"}\n'
'{"law_id": 2, "sta\n',
encoding="utf-8",
)
assert script.charger_verdicts(journal) == {
1: {"law_id": 1, "statut": "ok", "domaine": "B"}
}
def test_journal_absent(self, tmp_path):
assert script.charger_verdicts(tmp_path / "absent.jsonl") == {}