Spaces:
Runtime error
fix(reclassement): la simulation montre tout ce que --force ecrira
Browse filesIssus de la revue de la branche :
- `appliquer --dry-run` sans --force n'inscrivait pas dans
changements.csv les categories deja posees que le verdict contredit :
sur le corpus, ou toutes les lois en ont une, on relisait un fichier
presque vide avant un --force qui les reecrivait toutes. changements.csv
liste desormais toute loi dont la categorie changerait, avec sa suite
(appliquee, ou gardee faute de --force), et le README recommande
`--dry-run --force`.
- Une relance `--incertains` apres un arret sur quota repayait les lois
deja retentees avec un extrait : elle les saute.
- Une ligne de journal coupee par un arret brutal absorbait la ligne
suivante : le journal est termine par un saut de ligne avant d'ecrire.
- Le journal est indexe par l'identifiant de la loi : chaque verdict porte
desormais sa reference, et celui d'une autre loi (base reconstruite)
n'est ni repris ni applique.
- L'anomalie « Classement indisponible » posee par le pipeline est
effacee une fois la loi classee.
- Le commit toutes les 200 lois comptait les positions, et sautait
chaque fois que la 200e loi etait gardee ou a revoir : il compte les
lois ecrites.
Route /classifier/classify : un texte fait seulement de visas rendait
503 « reessayez plus tard », qu'aucun essai ne corrige ; il rend 422
(DocumentVide).
Comparaison sous Gemini : un budget epuise avant la premiere phrase leve
une erreur au lieu de rendre une reponse tronquee, et le second essai au
double n'avait pas lieu. GeminiBudgetEpuiseError le declenche.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- app/api/routes/classifier.py +8 -0
- app/services/comparison_service.py +5 -0
- app/services/gemini_service.py +10 -1
- app/services/legal_domain_classifier.py +8 -1
- scripts/maintenance/reclassify_domains.py +89 -13
- tests/test_api/test_classifier_routes.py +12 -0
- tests/test_scripts/test_reclassify_domains.py +95 -0
- tests/test_services/test_comparison_service.py +17 -0
- tests/test_services/test_legal_domain_classifier.py +10 -1
|
@@ -38,6 +38,7 @@ from app.models.user import User
|
|
| 38 |
from app.services.legal_domain_classifier import (
|
| 39 |
CANONICAL_DOMAINS,
|
| 40 |
ClassementIndisponible,
|
|
|
|
| 41 |
LegalDomainClassifier,
|
| 42 |
get_legal_domain_classifier,
|
| 43 |
)
|
|
@@ -130,6 +131,13 @@ async def classify_document(
|
|
| 130 |
# await : l'appel synchrone bloquait la boucle d'evenements, donc
|
| 131 |
# toutes les autres requetes, le temps de l'appel a Groq.
|
| 132 |
result = await classifier.classify_async(request.title, request.text, request.doc_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
except ClassementIndisponible as exc:
|
| 134 |
logger.warning("Classement indisponible : %s", exc.raison)
|
| 135 |
entetes = {}
|
|
|
|
| 38 |
from app.services.legal_domain_classifier import (
|
| 39 |
CANONICAL_DOMAINS,
|
| 40 |
ClassementIndisponible,
|
| 41 |
+
DocumentVide,
|
| 42 |
LegalDomainClassifier,
|
| 43 |
get_legal_domain_classifier,
|
| 44 |
)
|
|
|
|
| 131 |
# await : l'appel synchrone bloquait la boucle d'evenements, donc
|
| 132 |
# toutes les autres requetes, le temps de l'appel a Groq.
|
| 133 |
result = await classifier.classify_async(request.title, request.text, request.doc_type)
|
| 134 |
+
except DocumentVide as exc:
|
| 135 |
+
# Un texte fait seulement de visas n'a pas d'objet a classer : la
|
| 136 |
+
# demande est en cause, et un « reessayez plus tard » serait faux.
|
| 137 |
+
raise HTTPException(
|
| 138 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 139 |
+
detail="Rien à classer : ni titre, ni texte en dehors des visas.",
|
| 140 |
+
) from exc
|
| 141 |
except ClassementIndisponible as exc:
|
| 142 |
logger.warning("Classement indisponible : %s", exc.raison)
|
| 143 |
entetes = {}
|
|
@@ -52,6 +52,7 @@ from app.schemas.comparison import (
|
|
| 52 |
from app.schemas.search import ChunkResult, SearchFilters, SearchRequest
|
| 53 |
from app.services.article_reference import normalize_number
|
| 54 |
from app.services.llm import (
|
|
|
|
| 55 |
ERREURS_LLM,
|
| 56 |
ERREURS_QUOTA,
|
| 57 |
ERREURS_SATURATION,
|
|
@@ -343,6 +344,10 @@ class ComparisonService:
|
|
| 343 |
raise ComparisonQuotaError(str(e)) from e
|
| 344 |
except ERREURS_SATURATION as e:
|
| 345 |
raise ComparisonOverloadedError(str(e)) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
except ERREURS_LLM as e:
|
| 347 |
raise ComparisonError(str(e)) from e
|
| 348 |
|
|
|
|
| 52 |
from app.schemas.search import ChunkResult, SearchFilters, SearchRequest
|
| 53 |
from app.services.article_reference import normalize_number
|
| 54 |
from app.services.llm import (
|
| 55 |
+
ERREURS_BUDGET,
|
| 56 |
ERREURS_LLM,
|
| 57 |
ERREURS_QUOTA,
|
| 58 |
ERREURS_SATURATION,
|
|
|
|
| 344 |
raise ComparisonQuotaError(str(e)) from e
|
| 345 |
except ERREURS_SATURATION as e:
|
| 346 |
raise ComparisonOverloadedError(str(e)) from e
|
| 347 |
+
except ERREURS_BUDGET:
|
| 348 |
+
# Gemini : la reflexion a pris tout le budget avant la grille. Le
|
| 349 |
+
# cas meme que le second essai, au double, est fait pour corriger.
|
| 350 |
+
return None
|
| 351 |
except ERREURS_LLM as e:
|
| 352 |
raise ComparisonError(str(e)) from e
|
| 353 |
|
|
@@ -111,6 +111,15 @@ class GeminiServiceError(Exception):
|
|
| 111 |
pass
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
class GeminiQuotaError(GeminiServiceError):
|
| 115 |
"""
|
| 116 |
Quota du fournisseur epuise (429).
|
|
@@ -308,7 +317,7 @@ FORBIDDEN:
|
|
| 308 |
finish = _finish_reason(response)
|
| 309 |
logger.warning(f"⚠️ Reponse vide de Gemini (finish_reason={finish})")
|
| 310 |
if finish == "MAX_TOKENS":
|
| 311 |
-
raise
|
| 312 |
"Reponse tronquee : le budget de jetons a ete epuise avant "
|
| 313 |
"la premiere phrase."
|
| 314 |
)
|
|
|
|
| 111 |
pass
|
| 112 |
|
| 113 |
|
| 114 |
+
class GeminiBudgetEpuiseError(GeminiServiceError):
|
| 115 |
+
"""
|
| 116 |
+
Budget de jetons epuise AVANT la premiere phrase (la reflexion l'a pris).
|
| 117 |
+
|
| 118 |
+
Nommee a part : un appelant qui peut augmenter le budget — la comparaison
|
| 119 |
+
redemande au double — doit la reconnaitre sans lire le message.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
|
| 123 |
class GeminiQuotaError(GeminiServiceError):
|
| 124 |
"""
|
| 125 |
Quota du fournisseur epuise (429).
|
|
|
|
| 317 |
finish = _finish_reason(response)
|
| 318 |
logger.warning(f"⚠️ Reponse vide de Gemini (finish_reason={finish})")
|
| 319 |
if finish == "MAX_TOKENS":
|
| 320 |
+
raise GeminiBudgetEpuiseError(
|
| 321 |
"Reponse tronquee : le budget de jetons a ete epuise avant "
|
| 322 |
"la premiere phrase."
|
| 323 |
)
|
|
@@ -154,6 +154,13 @@ class ClassementIndisponible(Exception):
|
|
| 154 |
self.quota = quota
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
@dataclass(frozen=True)
|
| 158 |
class DomainResult:
|
| 159 |
"""
|
|
@@ -434,7 +441,7 @@ class LegalDomainClassifier:
|
|
| 434 |
titre = (title or "").strip()
|
| 435 |
extrait = extrait_pour_classement(content)
|
| 436 |
if not titre and not extrait:
|
| 437 |
-
raise
|
| 438 |
return DocumentAClasser(titre=titre, extrait=extrait, type_acte=doc_type or None)
|
| 439 |
|
| 440 |
def classify(
|
|
|
|
| 154 |
self.quota = quota
|
| 155 |
|
| 156 |
|
| 157 |
+
class DocumentVide(ClassementIndisponible):
|
| 158 |
+
"""
|
| 159 |
+
Rien a classer : ni titre, ni texte une fois les visas retires. Une erreur
|
| 160 |
+
de la DEMANDE, qu'aucun nouvel essai ne corrigera (422, pas 503).
|
| 161 |
+
"""
|
| 162 |
+
|
| 163 |
+
|
| 164 |
@dataclass(frozen=True)
|
| 165 |
class DomainResult:
|
| 166 |
"""
|
|
|
|
| 441 |
titre = (title or "").strip()
|
| 442 |
extrait = extrait_pour_classement(content)
|
| 443 |
if not titre and not extrait:
|
| 444 |
+
raise DocumentVide("document vide : ni titre, ni texte hors des visas")
|
| 445 |
return DocumentAClasser(titre=titre, extrait=extrait, type_acte=doc_type or None)
|
| 446 |
|
| 447 |
def classify(
|
|
@@ -28,8 +28,9 @@ Usage:
|
|
| 28 |
python scripts/maintenance/reclassify_domains.py classer --extrait 0 --lot 40
|
| 29 |
# 2. Les incertains, avec l'article premier, 15 par requete
|
| 30 |
python scripts/maintenance/reclassify_domains.py classer --incertains --extrait 500 --lot 15
|
| 31 |
-
# 3.
|
| 32 |
-
|
|
|
|
| 33 |
# 4. Ecrire en base, categories existantes comprises
|
| 34 |
python scripts/maintenance/reclassify_domains.py appliquer --force
|
| 35 |
"""
|
|
@@ -109,6 +110,16 @@ def _incertain(verdict: dict, seuil: float) -> bool:
|
|
| 109 |
return verdict.get("statut") != "ok" or float(verdict.get("confiance") or 0) < seuil
|
| 110 |
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
# ==================== CLASSER ====================
|
| 113 |
|
| 114 |
|
|
@@ -118,6 +129,7 @@ def lois_a_classer(
|
|
| 118 |
*,
|
| 119 |
incertains: bool = False,
|
| 120 |
seuil: float = 0.6,
|
|
|
|
| 121 |
law_ids: Optional[Sequence[int]] = None,
|
| 122 |
limite: Optional[int] = None,
|
| 123 |
) -> List[Law]:
|
|
@@ -126,13 +138,18 @@ def lois_a_classer(
|
|
| 126 |
|
| 127 |
Par defaut : celles sans verdict « ok » obtenu avec les consignes
|
| 128 |
actuelles. Avec `incertains` : celles dont le dernier verdict est « a
|
| 129 |
-
revoir » ou sous le seuil, a redemander avec un extrait
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"""
|
| 131 |
# Sans le contenu : 2 226 textes integraux en memoire pour lire des titres.
|
| 132 |
# Il n'est charge, loi par loi, que pour l'extrait de repli (extrait_de).
|
| 133 |
requete = (
|
| 134 |
session.query(Law)
|
| 135 |
-
.options(load_only(Law.id, Law.title, Law.type, Law.status))
|
| 136 |
.filter(Law.status == "published")
|
| 137 |
.order_by(Law.id)
|
| 138 |
)
|
|
@@ -141,8 +158,14 @@ def lois_a_classer(
|
|
| 141 |
lois = []
|
| 142 |
for loi in requete:
|
| 143 |
verdict = verdicts.get(loi.id)
|
|
|
|
|
|
|
| 144 |
if incertains:
|
| 145 |
-
garder =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
else:
|
| 147 |
garder = (
|
| 148 |
verdict is None
|
|
@@ -170,6 +193,21 @@ def extrait_de(session, loi: Law, longueur: int) -> str:
|
|
| 170 |
return extrait_pour_classement(premier or loi.content, longueur)
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
def _horodatage() -> str:
|
| 174 |
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 175 |
|
|
@@ -196,7 +234,8 @@ def classer(
|
|
| 196 |
"""Classe par lots et ajoute les verdicts au journal. Rend le code de sortie."""
|
| 197 |
lois = lois_a_classer(
|
| 198 |
session, charger_verdicts(journal),
|
| 199 |
-
incertains=incertains, seuil=seuil,
|
|
|
|
| 200 |
)
|
| 201 |
lots = [lois[i:i + taille_lot] for i in range(0, len(lois), taille_lot)]
|
| 202 |
logger.info(
|
|
@@ -204,6 +243,7 @@ def classer(
|
|
| 204 |
len(lois), len(lots), taille_lot, classifieur.modele, longueur_extrait,
|
| 205 |
)
|
| 206 |
journal.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 207 |
jetons_total = requetes_total = 0
|
| 208 |
debut = time.time()
|
| 209 |
|
|
@@ -243,6 +283,7 @@ def classer(
|
|
| 243 |
for loi, verdict in zip(lot, resultat.verdicts):
|
| 244 |
ligne = {
|
| 245 |
"law_id": loi.id,
|
|
|
|
| 246 |
"titre": (loi.title or "")[:200],
|
| 247 |
"statut": "ok" if verdict else "a_revoir",
|
| 248 |
"domaine": verdict.domain if verdict else None,
|
|
@@ -294,7 +335,12 @@ def appliquer(
|
|
| 294 |
"""
|
| 295 |
Ecrit les verdicts « ok » au-dessus du seuil. Sans `force`, une loi qui a
|
| 296 |
deja une autre categorie la garde (choix d'un administrateur, peut-etre).
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
"""
|
| 299 |
carte = load_domain_map(session)
|
| 300 |
noms = {identifiant: nom for nom, identifiant in session.query(Category.name, Category.id)}
|
|
@@ -304,18 +350,22 @@ def appliquer(
|
|
| 304 |
session.query(Law)
|
| 305 |
.options(load_only(
|
| 306 |
Law.id, Law.reference, Law.title, Law.category_id,
|
| 307 |
-
Law.category_confidence, Law.suggested_categories,
|
| 308 |
))
|
| 309 |
.filter(Law.id.in_(list(verdicts)))
|
| 310 |
.order_by(Law.id)
|
| 311 |
.all()
|
| 312 |
)
|
| 313 |
|
| 314 |
-
|
|
|
|
| 315 |
verdict = verdicts[loi.id]
|
| 316 |
actuelle = noms.get(loi.category_id, "(aucune)")
|
| 317 |
bilan.avant[actuelle] += 1
|
| 318 |
cible = carte.get((verdict.get("domaine") or "").lower())
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
if _incertain(verdict, seuil) or cible is None:
|
| 321 |
bilan.a_revoir += 1
|
|
@@ -330,6 +380,10 @@ def appliquer(
|
|
| 330 |
if loi.category_id not in (None, cible) and not force:
|
| 331 |
bilan.protegees += 1
|
| 332 |
bilan.apres[actuelle] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
continue
|
| 334 |
|
| 335 |
bilan.appliquees += 1
|
|
@@ -337,7 +391,8 @@ def appliquer(
|
|
| 337 |
if loi.category_id != cible:
|
| 338 |
bilan.changees += 1
|
| 339 |
changements.append([
|
| 340 |
-
loi.id, loi.reference, loi.title, actuelle, verdict["domaine"],
|
|
|
|
| 341 |
])
|
| 342 |
if not dry_run:
|
| 343 |
secondaires = [carte[nom.lower()] for nom in verdict.get("secondaires") or []
|
|
@@ -345,7 +400,11 @@ def appliquer(
|
|
| 345 |
loi.category_id = cible
|
| 346 |
loi.category_confidence = float(verdict["confiance"])
|
| 347 |
loi.suggested_categories = [cible, *[s for s in secondaires if s != cible]]
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
session.commit()
|
| 350 |
|
| 351 |
if not dry_run:
|
|
@@ -354,7 +413,7 @@ def appliquer(
|
|
| 354 |
dossier.mkdir(parents=True, exist_ok=True)
|
| 355 |
_ecrire_csv(
|
| 356 |
dossier / "changements.csv",
|
| 357 |
-
["law_id", "reference", "titre", "avant", "apres", "confiance"], changements,
|
| 358 |
)
|
| 359 |
_ecrire_csv(
|
| 360 |
dossier / "a_revoir.csv",
|
|
@@ -364,6 +423,22 @@ def appliquer(
|
|
| 364 |
return bilan
|
| 365 |
|
| 366 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
def _ecrire_csv(chemin: Path, entete: List[str], lignes: List[list]) -> None:
|
| 368 |
with chemin.open("w", encoding="utf-8", newline="") as sortie:
|
| 369 |
ecrivain = csv.writer(sortie)
|
|
@@ -378,7 +453,8 @@ def _rapport(bilan: Bilan, dossier: Path, dry_run: bool) -> None:
|
|
| 378 |
logger.info("%-55s %7d %7d", nom[:55], bilan.avant[nom], bilan.apres[nom])
|
| 379 |
logger.info(
|
| 380 |
"%s : %d verdicts appliques dont %d changements de categorie, %d categories "
|
| 381 |
-
"existantes gardees (--force pour les remplacer
|
|
|
|
| 382 |
"SIMULATION, rien n'est ecrit" if dry_run else "Ecrit en base",
|
| 383 |
bilan.appliquees, bilan.changees, bilan.protegees, bilan.a_revoir,
|
| 384 |
)
|
|
|
|
| 28 |
python scripts/maintenance/reclassify_domains.py classer --extrait 0 --lot 40
|
| 29 |
# 2. Les incertains, avec l'article premier, 15 par requete
|
| 30 |
python scripts/maintenance/reclassify_domains.py classer --incertains --extrait 500 --lot 15
|
| 31 |
+
# 3. Simuler EXACTEMENT ce que fera l'etape 4, puis relire le rapport,
|
| 32 |
+
# changements.csv et a_revoir.csv
|
| 33 |
+
python scripts/maintenance/reclassify_domains.py appliquer --dry-run --force
|
| 34 |
# 4. Ecrire en base, categories existantes comprises
|
| 35 |
python scripts/maintenance/reclassify_domains.py appliquer --force
|
| 36 |
"""
|
|
|
|
| 110 |
return verdict.get("statut") != "ok" or float(verdict.get("confiance") or 0) < seuil
|
| 111 |
|
| 112 |
|
| 113 |
+
def _meme_loi(verdict: dict, loi: Law) -> bool:
|
| 114 |
+
"""
|
| 115 |
+
Le verdict designe-t-il bien CETTE loi ? Le journal est indexe par
|
| 116 |
+
l'identifiant, qui change si la base est reconstruite ; la reference, non.
|
| 117 |
+
Une ligne d'avant ce controle (sans reference) est crue sur parole.
|
| 118 |
+
"""
|
| 119 |
+
reference = verdict.get("reference")
|
| 120 |
+
return reference is None or reference == loi.reference
|
| 121 |
+
|
| 122 |
+
|
| 123 |
# ==================== CLASSER ====================
|
| 124 |
|
| 125 |
|
|
|
|
| 129 |
*,
|
| 130 |
incertains: bool = False,
|
| 131 |
seuil: float = 0.6,
|
| 132 |
+
longueur_extrait: int = 0,
|
| 133 |
law_ids: Optional[Sequence[int]] = None,
|
| 134 |
limite: Optional[int] = None,
|
| 135 |
) -> List[Law]:
|
|
|
|
| 138 |
|
| 139 |
Par defaut : celles sans verdict « ok » obtenu avec les consignes
|
| 140 |
actuelles. Avec `incertains` : celles dont le dernier verdict est « a
|
| 141 |
+
revoir » ou sous le seuil, a redemander avec un extrait — sauf si ce
|
| 142 |
+
verdict a deja ete obtenu avec un extrait au moins aussi long : apres un
|
| 143 |
+
arret sur quota, la relance ne repaie pas les lois deja retentees.
|
| 144 |
+
|
| 145 |
+
Un verdict d'une AUTRE loi (meme identifiant, autre reference : base
|
| 146 |
+
reconstruite, autre base) compte comme absent.
|
| 147 |
"""
|
| 148 |
# Sans le contenu : 2 226 textes integraux en memoire pour lire des titres.
|
| 149 |
# Il n'est charge, loi par loi, que pour l'extrait de repli (extrait_de).
|
| 150 |
requete = (
|
| 151 |
session.query(Law)
|
| 152 |
+
.options(load_only(Law.id, Law.reference, Law.title, Law.type, Law.status))
|
| 153 |
.filter(Law.status == "published")
|
| 154 |
.order_by(Law.id)
|
| 155 |
)
|
|
|
|
| 158 |
lois = []
|
| 159 |
for loi in requete:
|
| 160 |
verdict = verdicts.get(loi.id)
|
| 161 |
+
if verdict is not None and not _meme_loi(verdict, loi):
|
| 162 |
+
verdict = None
|
| 163 |
if incertains:
|
| 164 |
+
garder = (
|
| 165 |
+
verdict is not None
|
| 166 |
+
and _incertain(verdict, seuil)
|
| 167 |
+
and int(verdict.get("extrait") or 0) < max(1, longueur_extrait)
|
| 168 |
+
)
|
| 169 |
else:
|
| 170 |
garder = (
|
| 171 |
verdict is None
|
|
|
|
| 193 |
return extrait_pour_classement(premier or loi.content, longueur)
|
| 194 |
|
| 195 |
|
| 196 |
+
def _terminer_la_derniere_ligne(journal: Path) -> None:
|
| 197 |
+
"""
|
| 198 |
+
Un arret brutal peut laisser une ligne coupee, sans saut de ligne : la
|
| 199 |
+
suivante s'y collerait, et les deux seraient perdues a la relecture.
|
| 200 |
+
"""
|
| 201 |
+
if not journal.exists() or journal.stat().st_size == 0:
|
| 202 |
+
return
|
| 203 |
+
with journal.open("rb") as fichier:
|
| 204 |
+
fichier.seek(-1, 2)
|
| 205 |
+
if fichier.read(1) == b"\n":
|
| 206 |
+
return
|
| 207 |
+
with journal.open("a", encoding="utf-8") as fichier:
|
| 208 |
+
fichier.write("\n")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
def _horodatage() -> str:
|
| 212 |
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 213 |
|
|
|
|
| 234 |
"""Classe par lots et ajoute les verdicts au journal. Rend le code de sortie."""
|
| 235 |
lois = lois_a_classer(
|
| 236 |
session, charger_verdicts(journal),
|
| 237 |
+
incertains=incertains, seuil=seuil, longueur_extrait=longueur_extrait,
|
| 238 |
+
law_ids=law_ids, limite=limite,
|
| 239 |
)
|
| 240 |
lots = [lois[i:i + taille_lot] for i in range(0, len(lois), taille_lot)]
|
| 241 |
logger.info(
|
|
|
|
| 243 |
len(lois), len(lots), taille_lot, classifieur.modele, longueur_extrait,
|
| 244 |
)
|
| 245 |
journal.parent.mkdir(parents=True, exist_ok=True)
|
| 246 |
+
_terminer_la_derniere_ligne(journal)
|
| 247 |
jetons_total = requetes_total = 0
|
| 248 |
debut = time.time()
|
| 249 |
|
|
|
|
| 283 |
for loi, verdict in zip(lot, resultat.verdicts):
|
| 284 |
ligne = {
|
| 285 |
"law_id": loi.id,
|
| 286 |
+
"reference": loi.reference,
|
| 287 |
"titre": (loi.title or "")[:200],
|
| 288 |
"statut": "ok" if verdict else "a_revoir",
|
| 289 |
"domaine": verdict.domain if verdict else None,
|
|
|
|
| 335 |
"""
|
| 336 |
Ecrit les verdicts « ok » au-dessus du seuil. Sans `force`, une loi qui a
|
| 337 |
deja une autre categorie la garde (choix d'un administrateur, peut-etre).
|
| 338 |
+
|
| 339 |
+
Ecrit changements.csv et a_revoir.csv dans tous les cas. changements.csv
|
| 340 |
+
liste TOUTE loi dont le verdict differe de la categorie actuelle, avec ce
|
| 341 |
+
qui lui arrive (`suite`) : appliquee, ou gardee faute de --force. Une
|
| 342 |
+
simulation sans --force ne montrait pas les categories qu'un --force
|
| 343 |
+
allait remplacer — precisement celles qu'il faut relire.
|
| 344 |
"""
|
| 345 |
carte = load_domain_map(session)
|
| 346 |
noms = {identifiant: nom for nom, identifiant in session.query(Category.name, Category.id)}
|
|
|
|
| 350 |
session.query(Law)
|
| 351 |
.options(load_only(
|
| 352 |
Law.id, Law.reference, Law.title, Law.category_id,
|
| 353 |
+
Law.category_confidence, Law.suggested_categories, Law.processing_error,
|
| 354 |
))
|
| 355 |
.filter(Law.id.in_(list(verdicts)))
|
| 356 |
.order_by(Law.id)
|
| 357 |
.all()
|
| 358 |
)
|
| 359 |
|
| 360 |
+
ecrites = 0
|
| 361 |
+
for loi in lois:
|
| 362 |
verdict = verdicts[loi.id]
|
| 363 |
actuelle = noms.get(loi.category_id, "(aucune)")
|
| 364 |
bilan.avant[actuelle] += 1
|
| 365 |
cible = carte.get((verdict.get("domaine") or "").lower())
|
| 366 |
+
if not _meme_loi(verdict, loi):
|
| 367 |
+
# Le verdict d'une autre loi : base reconstruite depuis le classement.
|
| 368 |
+
verdict = {**verdict, "statut": "autre_loi"}
|
| 369 |
|
| 370 |
if _incertain(verdict, seuil) or cible is None:
|
| 371 |
bilan.a_revoir += 1
|
|
|
|
| 380 |
if loi.category_id not in (None, cible) and not force:
|
| 381 |
bilan.protegees += 1
|
| 382 |
bilan.apres[actuelle] += 1
|
| 383 |
+
changements.append([
|
| 384 |
+
loi.id, loi.reference, loi.title, actuelle, verdict["domaine"],
|
| 385 |
+
verdict["confiance"], "gardee (--force pour appliquer)",
|
| 386 |
+
])
|
| 387 |
continue
|
| 388 |
|
| 389 |
bilan.appliquees += 1
|
|
|
|
| 391 |
if loi.category_id != cible:
|
| 392 |
bilan.changees += 1
|
| 393 |
changements.append([
|
| 394 |
+
loi.id, loi.reference, loi.title, actuelle, verdict["domaine"],
|
| 395 |
+
verdict["confiance"], "appliquee",
|
| 396 |
])
|
| 397 |
if not dry_run:
|
| 398 |
secondaires = [carte[nom.lower()] for nom in verdict.get("secondaires") or []
|
|
|
|
| 400 |
loi.category_id = cible
|
| 401 |
loi.category_confidence = float(verdict["confiance"])
|
| 402 |
loi.suggested_categories = [cible, *[s for s in secondaires if s != cible]]
|
| 403 |
+
loi.processing_error = _sans_anomalie_de_classement(loi.processing_error)
|
| 404 |
+
ecrites += 1
|
| 405 |
+
# Compte les lois ECRITES : compter les positions sautait le commit
|
| 406 |
+
# chaque fois que la 200e loi etait gardee ou a revoir.
|
| 407 |
+
if ecrites % LOIS_PAR_COMMIT == 0:
|
| 408 |
session.commit()
|
| 409 |
|
| 410 |
if not dry_run:
|
|
|
|
| 413 |
dossier.mkdir(parents=True, exist_ok=True)
|
| 414 |
_ecrire_csv(
|
| 415 |
dossier / "changements.csv",
|
| 416 |
+
["law_id", "reference", "titre", "avant", "apres", "confiance", "suite"], changements,
|
| 417 |
)
|
| 418 |
_ecrire_csv(
|
| 419 |
dossier / "a_revoir.csv",
|
|
|
|
| 423 |
return bilan
|
| 424 |
|
| 425 |
|
| 426 |
+
# Prefixe de l'anomalie que le pipeline pose quand le classement est
|
| 427 |
+
# indisponible (app/tasks/process_law.py). Elle n'a plus lieu d'etre une fois
|
| 428 |
+
# la loi classee ; les autres anomalies (pages illisibles, vecteurs) restent.
|
| 429 |
+
_ANOMALIE_DE_CLASSEMENT = "Classement indisponible"
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _sans_anomalie_de_classement(erreur: Optional[str]) -> Optional[str]:
|
| 433 |
+
if not erreur:
|
| 434 |
+
return erreur
|
| 435 |
+
restes = [
|
| 436 |
+
morceau for morceau in erreur.split(" | ")
|
| 437 |
+
if not morceau.startswith(_ANOMALIE_DE_CLASSEMENT)
|
| 438 |
+
]
|
| 439 |
+
return " | ".join(restes) or None
|
| 440 |
+
|
| 441 |
+
|
| 442 |
def _ecrire_csv(chemin: Path, entete: List[str], lignes: List[list]) -> None:
|
| 443 |
with chemin.open("w", encoding="utf-8", newline="") as sortie:
|
| 444 |
ecrivain = csv.writer(sortie)
|
|
|
|
| 453 |
logger.info("%-55s %7d %7d", nom[:55], bilan.avant[nom], bilan.apres[nom])
|
| 454 |
logger.info(
|
| 455 |
"%s : %d verdicts appliques dont %d changements de categorie, %d categories "
|
| 456 |
+
"existantes gardees (--force pour les remplacer ; elles figurent dans "
|
| 457 |
+
"changements.csv), %d a revoir",
|
| 458 |
"SIMULATION, rien n'est ecrit" if dry_run else "Ecrit en base",
|
| 459 |
bilan.appliquees, bilan.changees, bilan.protegees, bilan.a_revoir,
|
| 460 |
)
|
|
@@ -110,6 +110,18 @@ class TestClassify:
|
|
| 110 |
assert response.status_code == 422
|
| 111 |
assert classeur.titres == []
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
async def test_classement_indisponible_rend_503(self, admin_client: AsyncClient, classeur):
|
| 114 |
"""Jamais de domaine invente : l'ancien code rendait Droit Administratif."""
|
| 115 |
classeur.erreur = ClassementIndisponible("quota Groq", retry_after=41.2, quota=True)
|
|
|
|
| 110 |
assert response.status_code == 422
|
| 111 |
assert classeur.titres == []
|
| 112 |
|
| 113 |
+
async def test_texte_reduit_aux_visas_rend_422(self, admin_client: AsyncClient, classeur):
|
| 114 |
+
"""Une erreur de la demande, qu'un nouvel essai ne corrigera pas : pas de 503."""
|
| 115 |
+
from app.services.legal_domain_classifier import DocumentVide
|
| 116 |
+
|
| 117 |
+
classeur.erreur = DocumentVide("document vide")
|
| 118 |
+
|
| 119 |
+
response = await admin_client.post(
|
| 120 |
+
URL, json={"title": "", "text": "Vu la Constitution ;\nVu le décret n°2011/408 ;"}
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
assert response.status_code == 422
|
| 124 |
+
|
| 125 |
async def test_classement_indisponible_rend_503(self, admin_client: AsyncClient, classeur):
|
| 126 |
"""Jamais de domaine invente : l'ancien code rendait Droit Administratif."""
|
| 127 |
classeur.erreur = ClassementIndisponible("quota Groq", retry_after=41.2, quota=True)
|
|
@@ -177,6 +177,39 @@ class TestClasser:
|
|
| 177 |
assert derniers[lois[1]]["domaine"] == FINANCES
|
| 178 |
assert derniers[lois[1]]["extrait"] == 500
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
def _verdicts(lois, *domaines):
|
| 182 |
"""{law_id: verdict} : (domaine, confiance) ou None pour « a revoir »."""
|
|
@@ -209,6 +242,68 @@ class TestAppliquer:
|
|
| 209 |
FINANCES, FONCTION_PUBLIQUE,
|
| 210 |
]
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def test_force_applique_categorie_confiance_et_suggestions(self, base, tmp_path):
|
| 213 |
session, ids, lois = base
|
| 214 |
verdicts = _verdicts(lois, (FINANCES, 0.9))
|
|
|
|
| 177 |
assert derniers[lois[1]]["domaine"] == FINANCES
|
| 178 |
assert derniers[lois[1]]["extrait"] == 500
|
| 179 |
|
| 180 |
+
# Relance apres un arret : la loi 3, encore « a revoir » mais deja
|
| 181 |
+
# retentee avec un extrait, n'est pas repayee.
|
| 182 |
+
troisieme = _Classeur()
|
| 183 |
+
script.classer(
|
| 184 |
+
session, troisieme, journal=journal, taille_lot=15,
|
| 185 |
+
longueur_extrait=500, incertains=True,
|
| 186 |
+
)
|
| 187 |
+
assert troisieme.lots == []
|
| 188 |
+
|
| 189 |
+
def test_une_ligne_coupee_n_absorbe_pas_la_suivante(self, base, tmp_path):
|
| 190 |
+
session, ids, lois = base
|
| 191 |
+
journal = tmp_path / "verdicts.jsonl"
|
| 192 |
+
journal.write_text('{"law_id": 999, "titre": "Déc', encoding="utf-8")
|
| 193 |
+
|
| 194 |
+
script.classer(session, _Classeur([(FINANCES, 0.9)] * 5), journal=journal, taille_lot=5)
|
| 195 |
+
|
| 196 |
+
assert set(script.charger_verdicts(journal)) == set(lois)
|
| 197 |
+
|
| 198 |
+
def test_le_verdict_d_une_autre_loi_ne_compte_pas(self, base, tmp_path):
|
| 199 |
+
"""Base reconstruite : l'identifiant 1 designe maintenant une autre loi."""
|
| 200 |
+
session, ids, lois = base
|
| 201 |
+
journal = tmp_path / "verdicts.jsonl"
|
| 202 |
+
journal.write_text(json.dumps({
|
| 203 |
+
"law_id": lois[0], "reference": "AUTRE-LOI", "statut": "ok",
|
| 204 |
+
"domaine": FINANCES, "confiance": 0.9, "consignes": script.VERSION_DES_CONSIGNES,
|
| 205 |
+
}) + "\n", encoding="utf-8")
|
| 206 |
+
classeur = _Classeur([(FINANCES, 0.9)] * 5)
|
| 207 |
+
|
| 208 |
+
script.classer(session, classeur, journal=journal, taille_lot=5)
|
| 209 |
+
|
| 210 |
+
assert len(classeur.lots[0]) == 5
|
| 211 |
+
assert script.charger_verdicts(journal)[lois[0]]["reference"] == "R-1"
|
| 212 |
+
|
| 213 |
|
| 214 |
def _verdicts(lois, *domaines):
|
| 215 |
"""{law_id: verdict} : (domaine, confiance) ou None pour « a revoir »."""
|
|
|
|
| 242 |
FINANCES, FONCTION_PUBLIQUE,
|
| 243 |
]
|
| 244 |
|
| 245 |
+
def test_la_simulation_sans_force_montre_les_categories_qu_il_remplacera(self, base, tmp_path):
|
| 246 |
+
"""
|
| 247 |
+
Le cas du corpus : toutes les lois ont deja une categorie. Une
|
| 248 |
+
simulation qui n'en listait aucune laissait relire un fichier vide
|
| 249 |
+
avant un --force qui les reecrivait toutes.
|
| 250 |
+
"""
|
| 251 |
+
session, ids, lois = base
|
| 252 |
+
verdicts = _verdicts(lois, (FINANCES, 0.9))
|
| 253 |
+
|
| 254 |
+
bilan = script.appliquer(session, verdicts, dossier=tmp_path, dry_run=True)
|
| 255 |
+
|
| 256 |
+
assert bilan.protegees == 1
|
| 257 |
+
lignes = _csv(tmp_path / "changements.csv")
|
| 258 |
+
assert [(ligne["apres"], ligne["suite"]) for ligne in lignes] == [
|
| 259 |
+
(FINANCES, "gardee (--force pour appliquer)"),
|
| 260 |
+
]
|
| 261 |
+
|
| 262 |
+
def test_l_anomalie_de_classement_est_effacee(self, base, tmp_path):
|
| 263 |
+
session, ids, lois = base
|
| 264 |
+
session.execute(text("UPDATE laws SET processing_error = :e WHERE id = :id"), {
|
| 265 |
+
"id": lois[0],
|
| 266 |
+
"e": "page 3 illisible | Classement indisponible (quota Groq) : relancer "
|
| 267 |
+
"scripts/maintenance/reclassify_domains.py",
|
| 268 |
+
})
|
| 269 |
+
session.execute(text("UPDATE laws SET processing_error = :e WHERE id = :id"), {
|
| 270 |
+
"id": lois[1], "e": "Classement indisponible (panne) : relancer ...",
|
| 271 |
+
})
|
| 272 |
+
session.commit()
|
| 273 |
+
|
| 274 |
+
script.appliquer(
|
| 275 |
+
session, _verdicts(lois, (FINANCES, 0.9), (FINANCES, 0.9)), dossier=tmp_path, force=True
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
session.expire_all()
|
| 279 |
+
assert session.get(Law, lois[0]).processing_error == "page 3 illisible"
|
| 280 |
+
assert session.get(Law, lois[1]).processing_error is None
|
| 281 |
+
|
| 282 |
+
def test_commit_toutes_les_n_lois_ecrites(self, base, tmp_path, monkeypatch):
|
| 283 |
+
"""Compter les positions sautait le commit quand la n-ieme loi etait a revoir."""
|
| 284 |
+
session, ids, lois = base
|
| 285 |
+
monkeypatch.setattr(script, "LOIS_PAR_COMMIT", 2)
|
| 286 |
+
commits = []
|
| 287 |
+
vrai_commit = session.commit
|
| 288 |
+
monkeypatch.setattr(session, "commit", lambda: (commits.append(1), vrai_commit()))
|
| 289 |
+
verdicts = _verdicts(lois, (FINANCES, 0.9), None, (FINANCES, 0.9), None, (FINANCES, 0.9))
|
| 290 |
+
|
| 291 |
+
script.appliquer(session, verdicts, dossier=tmp_path, force=True)
|
| 292 |
+
|
| 293 |
+
# 3 lois ecrites : un commit a la 2e, puis le commit final.
|
| 294 |
+
assert len(commits) == 2
|
| 295 |
+
|
| 296 |
+
def test_le_verdict_d_une_autre_loi_n_est_pas_applique(self, base, tmp_path):
|
| 297 |
+
session, ids, lois = base
|
| 298 |
+
verdicts = _verdicts(lois, (FINANCES, 0.9))
|
| 299 |
+
verdicts[lois[0]]["reference"] = "AUTRE-LOI"
|
| 300 |
+
|
| 301 |
+
bilan = script.appliquer(session, verdicts, dossier=tmp_path, force=True)
|
| 302 |
+
|
| 303 |
+
session.expire_all()
|
| 304 |
+
assert bilan.a_revoir == 1
|
| 305 |
+
assert session.get(Law, lois[0]).category_id == ids[ADMINISTRATIF]
|
| 306 |
+
|
| 307 |
def test_force_applique_categorie_confiance_et_suggestions(self, base, tmp_path):
|
| 308 |
session, ids, lois = base
|
| 309 |
verdicts = _verdicts(lois, (FINANCES, 0.9))
|
|
@@ -579,6 +579,23 @@ class TestEchecs:
|
|
| 579 |
budgets = [appel.kwargs["max_tokens"] for appel in llm.generate.await_args_list]
|
| 580 |
assert budgets == [COMPARISON_MAX_TOKENS, COMPARISON_MAX_TOKENS_SECOND_ESSAI]
|
| 581 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
async def test_grille_coupee_redemandee_au_double(self, db_session, code_minier, llm):
|
| 583 |
llm.generate.side_effect = [
|
| 584 |
{"response": '{"lignes": [{"index": 0', "tronquee": True},
|
|
|
|
| 579 |
budgets = [appel.kwargs["max_tokens"] for appel in llm.generate.await_args_list]
|
| 580 |
assert budgets == [COMPARISON_MAX_TOKENS, COMPARISON_MAX_TOKENS_SECOND_ESSAI]
|
| 581 |
|
| 582 |
+
async def test_budget_epuise_avant_la_grille_redemande_au_double(
|
| 583 |
+
self, db_session, code_minier, llm
|
| 584 |
+
):
|
| 585 |
+
"""Gemini leve une erreur, sans texte, quand la reflexion prend tout le budget."""
|
| 586 |
+
from app.services.gemini_service import GeminiBudgetEpuiseError
|
| 587 |
+
|
| 588 |
+
llm.generate.side_effect = [
|
| 589 |
+
GeminiBudgetEpuiseError("budget epuise avant la premiere phrase"),
|
| 590 |
+
{"response": _grille(CRITERES_PAR_DEFAUT)},
|
| 591 |
+
]
|
| 592 |
+
service = ComparisonService(db_session, llm=llm)
|
| 593 |
+
|
| 594 |
+
resultat = await service.compare("permis de recherche", "permis d'exploitation")
|
| 595 |
+
|
| 596 |
+
assert len(resultat.rows) == len(CRITERES_PAR_DEFAUT)
|
| 597 |
+
assert llm.generate.await_args.kwargs["max_tokens"] == COMPARISON_MAX_TOKENS_SECOND_ESSAI
|
| 598 |
+
|
| 599 |
async def test_grille_coupee_redemandee_au_double(self, db_session, code_minier, llm):
|
| 600 |
llm.generate.side_effect = [
|
| 601 |
{"response": '{"lignes": [{"index": 0', "tronquee": True},
|
|
@@ -37,6 +37,7 @@ from app.services.legal_domain_classifier import (
|
|
| 37 |
SANTE,
|
| 38 |
ClassementIndisponible,
|
| 39 |
DocumentAClasser,
|
|
|
|
| 40 |
LegalDomainClassifier,
|
| 41 |
extrait_pour_classement,
|
| 42 |
get_legal_domain_classifier,
|
|
@@ -303,10 +304,18 @@ class TestUnDocument:
|
|
| 303 |
def test_document_vide(self):
|
| 304 |
classifieur, faux = classeur()
|
| 305 |
|
| 306 |
-
with pytest.raises(
|
| 307 |
classifieur.classify("", " ")
|
| 308 |
assert faux.appels == []
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
def test_aucun_verdict(self):
|
| 311 |
classifieur, faux = classeur(verdicts(), verdicts())
|
| 312 |
|
|
|
|
| 37 |
SANTE,
|
| 38 |
ClassementIndisponible,
|
| 39 |
DocumentAClasser,
|
| 40 |
+
DocumentVide,
|
| 41 |
LegalDomainClassifier,
|
| 42 |
extrait_pour_classement,
|
| 43 |
get_legal_domain_classifier,
|
|
|
|
| 304 |
def test_document_vide(self):
|
| 305 |
classifieur, faux = classeur()
|
| 306 |
|
| 307 |
+
with pytest.raises(DocumentVide):
|
| 308 |
classifieur.classify("", " ")
|
| 309 |
assert faux.appels == []
|
| 310 |
|
| 311 |
+
def test_texte_reduit_aux_visas_est_vide(self):
|
| 312 |
+
"""Les visas sont retires de l'extrait : il ne reste rien a classer."""
|
| 313 |
+
classifieur, faux = classeur()
|
| 314 |
+
|
| 315 |
+
with pytest.raises(DocumentVide):
|
| 316 |
+
classifieur.classify("", "Vu la Constitution ;\nVu le décret n°2011/408 ;")
|
| 317 |
+
assert faux.appels == []
|
| 318 |
+
|
| 319 |
def test_aucun_verdict(self):
|
| 320 |
classifieur, faux = classeur(verdicts(), verdicts())
|
| 321 |
|