rahim237cm Claude Opus 5.5 commited on
Commit
f508b99
·
1 Parent(s): d82ecf1

fix(reclassement): la simulation montre tout ce que --force ecrira

Browse files

Issus 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 CHANGED
@@ -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 = {}
app/services/comparison_service.py CHANGED
@@ -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
 
app/services/gemini_service.py CHANGED
@@ -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 GeminiServiceError(
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
  )
app/services/legal_domain_classifier.py CHANGED
@@ -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 ClassementIndisponible("document vide : ni titre ni contenu")
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(
scripts/maintenance/reclassify_domains.py CHANGED
@@ -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. Relire le rapport, changements.csv et a_revoir.csv
32
- python scripts/maintenance/reclassify_domains.py appliquer --dry-run
 
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 = verdict is not None and _incertain(verdict, seuil)
 
 
 
 
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, law_ids=law_ids, limite=limite,
 
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
- Ecrit changements.csv et a_revoir.csv dans tous les cas.
 
 
 
 
 
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
- for position, loi in enumerate(lois, start=1):
 
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"], verdict["confiance"],
 
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
- if position % LOIS_PAR_COMMIT == 0:
 
 
 
 
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), %d a revoir",
 
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
  )
tests/test_api/test_classifier_routes.py CHANGED
@@ -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)
tests/test_scripts/test_reclassify_domains.py CHANGED
@@ -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))
tests/test_services/test_comparison_service.py CHANGED
@@ -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},
tests/test_services/test_legal_domain_classifier.py CHANGED
@@ -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(ClassementIndisponible):
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