Lesson 9 — Mesurer et livrer

Projet final : 200 avis, une panne, un rapport

5 min read

This content is not translated yet. Showing the original version.

Projet final : 200 avis, une panne, un rapport

Le cahier des charges

Le service client veut, chaque lundi, le tableau des avis de la semaine : sentiment, catégorie, et la liste des clients à recontacter.

ExigenceOù c'est fait
classer chaque avis (sentiment, catégorie, à recontacter)classer_lot + SCHEMA (classer.py)
ne pas saturer le quotaSemaphore + Debit + réessais exponentiels
survivre à une coupure sans repayerresultats.jsonl (reprise) + ids stables
un rapport exploitable dans un tableurrapport.csv (rapport.py)
connaître le coût et la qualitéjetons enregistrés, contrôles, échantillon
pouvoir passer en mode lot moins cherbatch_api.py
flowchart LR
  G["generer_avis.py"] --> A["avis.csv : 200 avis"]
  A --> C1["classer.py : 1re exécution"]
  C1 -->|"Ctrl-C après 8 s"| J["resultats.jsonl : 3 lots"]
  J --> C2["classer.py : relance"]
  C2 --> J2["resultats.jsonl : 20 lots"]
  J2 --> C3["classer.py : relance à vide (idempotence)"]
  J2 --> R["rapport.py"]
  R --> O["rapport.csv, echantillon_controle.csv, statistiques"]

Étape 1 : préparer

terminal
cd files
pip install -r requirements.txt pytest
cp .env.example .env               # mettez votre clé, et les prix réels du modèle
pytest -q                          # 9 tests sans clé : réessais, reprise, concurrence, rapport, JSONL
python generer_avis.py             # 200 avis écrits dans avis.csv

Les avis sont produits par des modèles de phrases avec une graine fixe : tout le monde obtient les mêmes 200 avis, y compris les cas difficiles (ironie, avis mitigés).

Étape 2 : classer, et couper en plein travail

terminal
timeout -s INT 8 python classer.py --rpm 30     # équivalent d'un Ctrl-C au bout de 8 s
wc -l resultats.jsonl                            # quelques lots déjà enregistrés
python classer.py --rpm 30                       # reprise
python classer.py --rpm 30                       # rien à faire : idempotent

timeout -s INT 8 envoie le même signal qu'un Ctrl-C au bout de 8 secondes : c'est une vraie interruption, pas une simulation dans le code.

Après la coupure, resultats.jsonl contient 3 lignes. Combien d'avis la relance va-t-elle traiter ?

Étape 3 : le rapport

terminal
python rapport.py

Le rapport produit rapport.csv (une ligne par avis classé), echantillon_controle.csv (20 lignes au hasard à relire) et affiche les statistiques.

Étape 4 (facultative) : le mode lot

terminal
python batch_api.py gemini --n 20                # Batch API, clé d'API
python rapport.py --resultats resultats_batch.jsonl

Et sur Google Cloud, avec un bucket à votre nom : voir la leçon 3.2, sans oublier le nettoyage (bucket et job).

Le lundi suivant, 1 000 nouveaux avis arrivent. Que faut-il changer pour éviter de reclasser ceux de la semaine précédente ? (plusieurs réponses)

Exercice : la liste à recontacter

Le service client veut un fichier a_recontacter.csv avec seulement les avis à recontacter, les plus négatifs en premier (note croissante). Écrivez-le en quelques lignes à partir de rapport.csv.

Voir la solution
a_recontacter.py
import csv

with open("rapport.csv", newline="", encoding="utf-8") as f:
    lignes = [l for l in csv.DictReader(f) if l["a_recontacter"] == "True"]
lignes.sort(key=lambda l: (int(l["note"]), l["id"]))

with open("a_recontacter.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=["id", "produit", "note", "categorie", "texte"], extrasaction="ignore")
    w.writeheader()
    w.writerows(lignes)
print(len(lignes), "avis à recontacter")

Le CSV stocke les booléens en texte : on compare à "True". Le tri sur (note, id) donne un ordre stable d'une exécution à l'autre.

Résultat obtenu

Exécution complète du 2026-10-15 (gemini-3.5-flash-lite, clé d'API, prix d'exemple) :

$ python generer_avis.py
200 avis écrits dans avis.csv
=== 1re exécution, interrompue (Ctrl-C) après 8 s
0 avis déjà traités, 200 à faire en 20 lots
  lot A0001..A0010 : 10/10 avis
  lot A0011..A0020 : 10/10 avis
  lot A0021..A0030 : 10/10 avis
Interrompu : relancez la même commande pour reprendre là où vous en étiez.
3 resultats.jsonl
=== relance
30 avis déjà traités, 170 à faire en 17 lots
  lot A0031..A0040 : 10/10 avis
  ...
=== relance à nouveau (idempotence)
200 avis déjà traités, 0 à faire en 0 lots
Terminé en 0.2 s : {'lots_ok': 0, 'lots_echec': 0, 'avis_ok': 0}
=== rapport
Avis classés : 200/200  (manquants : 0 [])
Sentiments  : {'negatif': 112, 'positif': 43, 'neutre': 45}
Catégories  : {'livraison': 44, 'qualite_produit': 58, 'site_web': 36, 'service_client': 27, 'prix': 35}
À recontacter : 83
Incohérences note/sentiment : 0 []
Accord avec les réponses attendues : sentiment 96%, catégorie 100% ; sur l'échantillon de 20 : 100%
Jetons : 6884 en entrée, 9468 en sortie ; coût estimé au tarif standard 0.0045 $ (au tarif Batch, moitié prix : 0.0022 $)
Fichiers : rapport.csv, echantillon_controle.csv

Le premier essai d'interruption avait aussi affiché une trace d'erreur Event loop is closed : les connexions HTTP n'étaient pas fermées avant l'arrêt. Ouvrir le client avec async with genai.Client().aio as client a réglé le problème, comme le montre la sortie ci-dessus.

Je sais faire

  • Lancer le pipeline complet : génération, classement, rapport
  • Interrompre un traitement et le reprendre sans perte ni double paiement
  • Lire les statistiques, le coût et le résultat du contrôle qualité
  • Choisir quand basculer en mode lot (Batch API ou Vertex AI)