Leçon 3 — Construire le générateur

Le brief YAML et le générateur

8 min de lecture

Le brief YAML et le générateur

Dans la plupart des équipes, les prompts d'une campagne vivent dans un fil de discussion, une feuille de calcul et l'historique de navigateur de deux personnes. Trois semaines plus tard, quand la direction demande la même image au format carré, personne ne sait quel prompt, quel modèle ni quelle image de référence l'avait produite. Le brief devient ici un fichier versionné, validé avant le moindre appel, et chaque image produite garde la trace de ce qui l'a créée.

Le mécanisme

sequenceDiagram
  participant U as Vous
  participant P as pipeline.py
  participant B as brief.py
  participant G as generateur.py
  participant M as Gemini
  U->>P: python -m campagne brief.yaml
  P->>B: charger() + avertissements()
  B-->>P: Brief validé ou BriefInvalide
  P->>P: estimer() : total > budget ? arrêt
  loop chaque visuel
    P->>G: session(modèle, ratio, taille)
    G->>M: prompt + images de référence
    M-->>G: image + usage_metadata
    G-->>P: Rendu (image, durée, coût)
  end
  P->>U: sorties/ + manifest.json + planche.html

Trois choix structurent le code :

  • le brief est validé par un schéma (pydantic) : un canal mal orthographié ou une référence sans droits arrête tout avant la première dépense ;
  • une session de conversation par visuel (client.chats.create) : la génération initiale et ses retouches partagent l'historique ; la documentation présente la conversation multi-tours comme la façon recommandée d'itérer sur une image ;
  • le budget est vérifié avant le premier appel, sur une estimation fondée sur le prix fixe par image.

La mise en œuvre

Le schéma du brief

files/campagne/brief.py (extrait)
class Reference(BaseModel):
    id: str
    fichier: str
    role: Literal["produit", "personnage", "charte", "style"]
    droits: str = Field(min_length=3, description="Source et licence : qui a fourni l'image, à quel titre")


class Visuel(BaseModel):
    id: str = Field(pattern=r"^[a-z0-9-]+$")
    usage: Literal["produit", "rh", "editorial", "evenement", "autre"] = "autre"
    canaux: list[str] = Field(min_length=1)
    sujet: str
    composition: str = ""
    cadrage: str = ""                 # plan, angle, focale
    lumiere: str = ""
    matiere: str = ""
    style: str = ""
    contraintes: list[str] = []
    negatifs: list[str] = []
    references: list[str] = []        # ids de Reference
    retouches: list[str] = []         # instructions d'édition multi-tours, dans l'ordre
    texte: str | None = None          # superposé en post-production
    final: bool = False               # True : rendu par le modèle final

Le champ droits est obligatoire et non vide : une image de référence dont personne ne sait d'où elle vient ne doit pas entrer dans le pipeline. À côté des erreurs bloquantes, avertissements() applique des règles de studio qui ne bloquent pas : prompt sous-spécifié (moins de deux rubriques parmi composition, cadrage, lumière), visuel produit sans image de référence, logo demandé au modèle, titre trop long.

La session et les reprises

files/campagne/generateur.py (extrait)
def envoyer(self, parts: list) -> Rendu:
    for essai in range(1, self.tentatives + 1):
        debut = time.perf_counter()
        try:
            rep = self.chat.send_message(parts)
            break
        except errors.APIError as e:
            if e.code not in CODES_TRANSITOIRES or essai == self.tentatives:
                raise
            pause = self.attente * 2 ** (essai - 1)
            log.warning("%s : erreur %s, nouvel essai dans %.0f s", self.modele, e.code, pause)
            self.dormir(pause)
    ...
    images = [p.inline_data for p in parts_rep if p.inline_data and not getattr(p, "thought", False)]
    if not images:
        raison = getattr(cand, "finish_reason", None) or getattr(rep.prompt_feedback, "block_reason", None)
        raise AucuneImage(f"{self.modele} : pas d'image (raison : {raison}) {texte[:200]}")

Seules les erreurs transitoires (429, 500, 503, 504) sont réessayées. Une 400 (argument invalide) ne s'arrangera pas en réessayant. Une réponse sans image n'est pas une exception de l'API : le modèle répond normalement avec du texte et une raison d'arrêt (IMAGE_SAFETY, par exemple). Le pipeline marque alors le visuel en échec, continue les suivants, et la planche l'affiche en rouge.

La configuration de génération porte le ratio et la taille :

files/campagne/generateur.py (extrait)
config = types.GenerateContentConfig(
    response_modalities=["TEXT", "IMAGE"],
    image_config=types.ImageConfig(aspect_ratio=ratio, image_size=taille),
)
return Session(self.client.chats.create(model=modele, config=config), modele, **self.options)

Le garde-fou de budget

files/campagne/pipeline.py (extrait)
plans = planifier(brief, modele, tout_final)
total_estime = sum(p.estimation_usd for p in plans)
plafond = budget if budget is not None else brief.budget_usd
if total_estime > plafond:
    raise BudgetDepasse(f"estimation {total_estime:.2f} USD > budget {plafond:.2f} USD : rien n'a été généré")

Les tests le vérifient avec un faux client qui enregistre chaque appel : avec un budget de 0,10 $, le journal des appels reste vide. Tous les tests utilisent ce faux client (conftest.py), ce qui permet de tester la campagne complète (5 visuels, 6 appels, recadrages, planche) en une seconde, sans clé.

Résultat obtenu

Les erreurs du brief sont attrapées avant tout appel :

terminal, 2026-10-15
$ .venv/bin/python -m campagne tests/_t.yaml --dry-run      # taille: 512 sans guillemets
ERREUR tests/_t.yaml : taille : Input should be '512', '1K', '2K' or '4K'
$ .venv/bin/python -m campagne tests/_t.yaml --dry-run      # canal « linkedin »
ERREUR tests/_t.yaml : visuels.0.canaux : Value error, canal inconnu ['linkedin'], attendus : ['instagram_feed', 'instagram_story', 'linkedin_carre', 'linkedin_post', 'print_a5', 'site_hero', 'site_vignette']
$ .venv/bin/python -m campagne brief.yaml --budget 0.3
ERREUR estimation 0.56 USD > budget 0.30 USD : rien n'a été généré

Puis la campagne réelle :

make run, 2026-10-15 (clé d
20:43:03 INFO gemini-3-pro-image : image en 22.9 s, 0.1411 USD
20:43:11 INFO gemini-3.1-flash-image : image en 8.0 s, 0.0685 USD
20:43:20 INFO gemini-3.1-flash-image : image en 8.3 s, 0.0683 USD
20:43:30 INFO gemini-3.1-flash-image : image en 9.7 s, 0.0689 USD
20:43:38 INFO gemini-3.1-flash-image : image en 8.5 s, 0.0682 USD
20:44:04 INFO gemini-3-pro-image : image en 25.6 s, 0.1416 USD
5/5 visuels, coût réel 0.557 $ -> sorties/campagne/planche.html

Six appels pour cinq visuels : le deuxième visuel a une retouche, envoyée dans la même session. Durée totale : environ 1 minute 25.

L'illustration éditoriale du brief : vue isométrique, aplats, palette de la marque (Flash 1K, 8,5 s, 0,068 $)

Ce qui casse en production

SymptômeCauseParade
taille : Input should be '512'…taille: 512 sans guillemets : YAML lit un entierguillemets dans le brief ; le message d'erreur nomme le champ
Toute la campagne s'arrête au troisième visuelune réponse sans image levée comme exception fataleAucuneImage attrapée par visuel, statut echec, la suite continue
La retouche ignore l'image précédenteretouche envoyée dans un nouvel appel sans historiqueune session de chat par visuel ; la retouche part seule dans la session
Un lot de 200 visuels échoue à moitié sur des 429pas de reprise, ou reprise immédiate qui aggraveattente exponentielle (5, 10, 20 s) limitée à 4 essais ; au-delà, API Batch
client has been closed en plein lotclient genai.Client() créé dans une fonction puis libéréun seul client, créé dans main() et passé au pipeline
Le même brief, relancé, écrase la campagne validéesortie unique sorties/campagne--sortie sorties/<date> pour chaque lancement à conserver

La règle de décision

Mettez le brief sous git dès la deuxième campagne. Rendez obligatoire tout champ dont l'absence vous a déjà coûté une régénération (les droits des références d'abord). Gardez en avertissement, et non en erreur, les règles de goût : un directeur artistique doit pouvoir passer outre en connaissance de cause.

Pratique

Sur un lot de 60 visuels, 3 reviennent sans image avec finish_reason IMAGE_SAFETY. Quel comportement attendez-vous du pipeline ?

Un collègue propose de mettre budget_usd: 1000 « pour ne jamais être bloqué ». Le brief contient 12 visuels. Que lui répondez-vous ?

Exercice. Ajoutez au brief un visuel newsletter-bandeau pour le canal site_hero, sans image de référence, avec seulement un sujet et un style. Lancez --dry-run : quelles alertes apparaissent, et comment les levez-vous ?

Voir la solution
  - id: newsletter-bandeau
    usage: produit
    canaux: [site_hero]
    sujet: "la lampe Orbe sur une table de chevet"
    style: "photographie d'intérieur"

Le dry-run affiche deux alertes : newsletter-bandeau : prompt sous-spécifié (composition, cadrage, lumière : au moins 2 sur 3) et newsletter-bandeau : visuel produit sans image de référence, le produit sera réinventé. On les lève en ajoutant references: [orbe] et au moins deux rubriques, par exemple composition: "lampe au tiers gauche, mur uni à droite pour le titre" et lumiere: "lumière de lecture chaude, pièce dans la pénombre".

  • Je sais décrire une campagne dans un brief YAML validé
  • Je distingue erreur bloquante et avertissement de studio
  • Je sais pourquoi une session de chat par visuel
  • Je ne réessaie que les erreurs transitoires
  • J'ai lancé make run et ouvert sorties/campagne/planche.html
Suivant