Les contrôles automatiques disent si un visuel est lisible et propre. Ils ne disent pas s'il est juste : si le flacon a l'air d'un flacon de whisky, si la lumière est trop dramatique pour une lessive, si la story fait cheap. Cette décision appartient à la responsable de marque. Il lui faut un écran où elle voit chaque visuel avec ses contrôles, approuve en un clic, ou écrit « flacon plus à droite » et obtient une nouvelle version en quinze secondes. Et le système doit refuser d'exporter ce qu'elle n'a pas approuvé.
Le mécanisme
sequenceDiagram participant U as Responsable de marque participant W as Page web (index.html) participant A as FastAPI (web.py) participant S as Studio (pipeline.py) participant G as Gemini U->>W: ouvre la galerie W->>A: GET /api/campagne A->>S: manifest.json (sans appel modèle) U->>W: « flacon plus à droite » + Régénérer W->>A: POST /api/assets/story/regenerer A->>S: regenerer() sous verrou S->>G: édition dans la conversation du format G-->>S: nouveau fond S->>S: calques, contrôles, version +1, statut « à valider » A-->>W: asset mis à jour U->>W: Approuver W->>A: POST /api/assets/story/approuver
Trois décisions de conception :
- Le manifeste JSON est la seule source de vérité. Chaque version (fond, rendu, consigne, contrôles, date) y est ajoutée, jamais écrasée. L'écriture passe par un fichier temporaire puis
replace(): la galerie ne lit jamais un fichier à moitié écrit. - La galerie s'ouvre sans clé. Le client Gemini n'est créé qu'au premier besoin (
_Paresseux) : relire et approuver ne coûtent rien et ne demandent aucun identifiant. - Toute modification remet le statut à « à valider ». Une régénération ou un
make recomposerannule l'approbation du visuel concerné.
La mise en œuvre
Les routes, dans web.py :
class Consigne(BaseModel):
consigne: str = Field(min_length=3, max_length=500)
@app.post("/api/assets/{nom}/regenerer")
def regenerer(nom: str, corps: Consigne):
with verrou: # un clic à la fois : deux éditions simultanées du même format mélangeraient les versions
try:
return studio().regenerer(nom, corps.consigne)
except KeyError:
raise HTTPException(404, f"visuel inconnu : {nom}")
except Exception as e: # quota, budget, refus du modèle : l'interface affiche le message
raise HTTPException(502, f"régénération impossible : {e}")
app.mount("/media", StaticFiles(directory=dossier), name="media")La page (web/index.html) est un seul fichier HTML sans build. Tout texte issu du modèle (ton, règles, éléments détectés, consignes) passe par esc() avant d'entrer dans le DOM : un modèle peut produire <img onerror=…> aussi bien qu'un humain. Les images sont appelées avec ?v=<version> pour que le navigateur ne serve pas l'ancienne version depuis son cache.
L'export, dans pipeline.py, vérifie les statuts :
manquants = [n for n in ("instagram_portrait", "linkedin") if self.manifest["assets"][n]["statut"] != "approuve"]
if self.manifest.get("video") and self.manifest["video"]["statut"] != "approuve":
manquants.append("video")
if manquants:
raise PermissionError(f"export refusé : à approuver d'abord : {', '.join(manquants)}")make test # test_galerie_approuver_regenerer : approbation, régénération, 404, 422, /media/../manifest.json refusé
make run # http://127.0.0.1:8000Résultat obtenu
Session réelle du 2026-10-15, pilotée par Playwright sur la campagne Ondée : deux régénérations de la story depuis la galerie (15,0 s puis 14,2 s, 0,068 $ chacune), puis approbation des cinq éléments. L'export LinkedIn avait été refusé plus tôt dans la journée tant que les visuels n'étaient pas approuvés :
PermissionError: export refusé : à approuver d'abord : instagram_portrait, linkedin
...
linkedin:approuve:v1 instagram_portrait:approuve:v1 story:approuve:v3 banniere:approuve:v1 video:approuve cout 1.1569Le coût cumulé affiché (1,16 $) inclut tout ce que la campagne a consommé : génération initiale, deux régénérations de story, et le second clip Veo de la leçon précédente.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Deux versions « v3 » de la même story | double clic, deux requêtes parallèles | verrou côté serveur, boutons désactivés pendant la requête côté page |
| La galerie affiche une page blanche pendant une régénération longue | requête synchrone de 15 s sur un serveur à un seul worker | acceptable pour une équipe de 4 ; au-delà, file de tâches et sondage du statut (le commentaire ponytail: dans web.py le signale) |
| Un visuel approuvé hier est publié avec un autre texte | make recomposer lancé après l'approbation | toute recomposition repasse les visuels « à valider » |
GET /media/../manifest.json renvoie le manifeste | montage de fichiers statiques maison | StaticFiles de Starlette normalise le chemin ; le test vérifie le 404 |
| Une consigne de 20 000 caractères part au modèle | pas de limite d'entrée | Field(min_length=3, max_length=500) : 422 avant tout appel |
La règle de décision
L'humain décide, la machine prépare et trace. Chaque approbation porte un auteur et une date ; chaque version garde sa consigne et ses contrôles. Ne laissez jamais un export contourner l'approbation, même « pour gagner du temps » : le test de bout en bout lui-même approuve explicitement, avec approuve_par="e2e".
Pratique
La responsable approuve la story v3, puis le juriste fait corriger le sous-titre et vous lancez make recomposer. Elle veut exporter. Que se passe-t-il ?
Recomposer change ce qui sera publié ; l'approbation portait sur l'ancien rendu. Le statut repasse à « à valider ». C'est une friction voulue : elle empêche de publier un texte que personne n'a vu dans son contexte.
Trois personnes vont utiliser la galerie en même temps sur des campagnes différentes. Quelle est la première évolution ?
Le verrou global sérialise des campagnes indépendantes : un verrou par dossier de campagne suffit. Avec plusieurs utilisateurs, savoir qui a approuvé devient obligatoire. Le framework de la page n'est pas le goulot.
Exercice. Ajoutez un bouton « Rejeter » qui passe le statut à rejete avec un motif obligatoire, et faites en sorte que l'export refuse aussi un visuel rejeté.
Voir la solution
# pipeline.py
def rejeter(self, nom: str, motif: str, par: str = "humain") -> dict:
if len(motif.strip()) < 3:
raise ValueError("motif obligatoire")
a = self._asset(nom)
a.update(statut="rejete", rejete_par=par, motif=motif.strip()[:500], rejete_le=_now())
self.sauver()
return a
# web.py
@app.post("/api/assets/{nom}/rejeter")
def rejeter(nom: str, corps: Consigne):
with verrou:
try:
return studio().rejeter(nom, corps.consigne)
except KeyError:
raise HTTPException(404, f"visuel inconnu : {nom}")L'export n'a rien à changer : il exige statut == "approuve", donc un visuel rejeté est déjà refusé. Ajoutez le test : rejeter linkedin, vérifier que exporter_linkedin lève PermissionError.
- Je sais exposer une validation humaine avec FastAPI et la tester avec
TestClient - Je sais écrire un manifeste de façon atomique
- Je sais échapper le texte produit par un modèle avant de l'afficher
- Je sais empêcher un export non approuvé