Leçon 6 — Contrôler et publier

Galerie et validation humaine

50 min

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 recomposer annule l'approbation du visuel concerné.

La mise en œuvre

Les routes, dans web.py :

files/studio/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 :

files/studio/pipeline.py
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:8000

Ré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.1569

Le 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.

Galerie après validation, 1440 px de large
Galerie sur un téléphone de 390 px : les visuels d'abord, le kit ensuite

Ce qui casse en production

SymptômeCauseParade
Deux versions « v3 » de la même storydouble clic, deux requêtes parallèlesverrou 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 longuerequête synchrone de 15 s sur un serveur à un seul workeracceptable 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 textemake recomposer lancé après l'approbationtoute recomposition repasse les visuels « à valider »
GET /media/../manifest.json renvoie le manifestemontage de fichiers statiques maisonStaticFiles de Starlette normalise le chemin ; le test vérifie le 404
Une consigne de 20 000 caractères part au modèlepas de limite d'entréeField(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 ?

Trois personnes vont utiliser la galerie en même temps sur des campagnes différentes. Quelle est la première évolution ?

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é
Suivant