Le point de départ : un formulaire, un site
Le besoin tient en une phrase : quelqu’un remplit un formulaire — nom de l’activité, trois ou quatre services, un ton, éventuellement un logo, deux photos, des horaires — et récupère un dossier statique complet, prêt à être servi : index.html, mentions légales, politique de confidentialité, assets/, robots.txt, sitemap.xml.
Entre les deux, un modèle. La question n’est pas « est-ce qu’un LLM sait écrire une page vitrine » — il sait, et c’est bien le problème. La question est : qu’est-ce qu’on lui laisse écrire au juste ?
La première réponse, naïve : « génère-moi le HTML »
Demander la page complète au modèle marche à la première tentative et se dégrade à toutes les suivantes. Trois symptômes reviennent :
- Le rendu n’est jamais deux fois le même. Deux briefs quasi identiques donnent deux structures de page différentes, deux échelles typographiques, deux systèmes d’espacement. Impossible d’avoir une identité de produit.
- La qualité est plafonnée par le pire échantillon. Un contraste texte/fond à 3,1:1 dans un cas sur vingt suffit à rendre le résultat inutilisable, et rien ne l’attrape : c’est du HTML valide.
- On ne peut rien corriger globalement. Une amélioration de la grille ou de la palette devrait bénéficier à tous les sites déjà générés. Si chaque site est un artefact unique produit par un modèle, il n’y a rien à corriger — il faut tout régénérer, et espérer.
Le constat qui suit est banal mais structurant : ce qu’on veut du modèle, ce n’est pas du code. C’est du jugement éditorial — quelle direction graphique colle à un atelier de réparation de vélos, dans quel ordre présenter les services, quel titre accroche — et du texte. Le reste appartient au code.
Le parti pris : le modèle choisit, le code rend
D’où l’inversion. Le modèle ne produit ni HTML, ni CSS, ni couleur. Il produit une composition : un objet JSON qui désigne, dans un catalogue fermé, ce que le site doit contenir.
Le catalogue vit dans le code et n’existe qu’une fois :
- 10 directions graphiques — chacune est un jeu de jetons (deux familles de polices embarquées en woff2, une palette d’accents, des fondations CSS) validé une fois pour toutes, contraste WCAG compris.
- 11 types de section —
hero,facts,services,steps,about,gallery,testimonial,hours,values,cta-band,contact— chacun avec ses variantes de mise en page. - 40 icônes SVG et une poignée de graphiques décoratifs, nommés.
Le modèle choisit une direction, un accent, une suite de sections avec leurs variantes, et rédige les textes. Un hero rendu sera toujours un hero rendu par le même template, avec les mêmes marges, la même échelle typographique et un contraste déjà mesuré.
Corollaire important : le prompt qui décrit le catalogue est engendré depuis le code du catalogue, pas recopié à côté. Un type de section ajouté au code apparaît dans le prompt au prochain appel ; il n’existe pas d’univers où le modèle connaît une variante que le moteur de rendu ignore.
Étage 1 : un brouillon qui ne peut pas échouer
Première tentation : donner au modèle le schéma strict directement, en sortie structurée, et laisser le fournisseur garantir la conformité. Mauvaise idée, pour une raison très concrète.
Les 11 types de section n’ont pas les mêmes champs. Un hero a un headline, un subheadline, deux libellés de bouton, un graphique ; une gallery a des légendes ; une contact a une mention de confidentialité. Exprimer ça en sortie structurée, c’est une union de 11 objets — et une union profonde, avec des champs requis distincts par branche, est exactement le genre de schéma sur lequel un modèle produit des réponses formellement valides mais sémantiquement approximatives : il remplit un champ avec une chaîne vide plutôt que de l’omettre, ou range une légende dans le mauvais type.
Le brouillon prend le contre-pied : tout est optionnel, une chaîne vide vaut absence.
class SectionDraft(BaseModel):
type: str
variant: str
title: str = ""
intro: str = ""
headline: str = ""
subheadline: str = ""
cta_primary: str = ""
graphic: str = ""
paragraphs: list[str] = Field(default_factory=list)
captions: list[str] = Field(default_factory=list)
items: list[ItemDraft] = Field(default_factory=list)
Un seul objet plat pour les 11 types, aucun champ requis au-delà de type et variant. Le modèle ne peut pratiquement pas produire une réponse que la sortie structurée rejette. Ce n’est pas du laxisme : c’est le refus de mélanger deux erreurs très différentes — « le modèle a mal formé son JSON » et « le modèle a composé un site invalide ». La première ne doit plus jamais arriver ; la seconde est intéressante et mérite un message.
Étage 2 : la porte stricte
Le brouillon est ensuite converti vers la forme réelle, et c’est là que tout se joue. Union discriminée sur type, Literal construits depuis le catalogue, longueurs bornées champ par champ :
DirectionName = Literal[*DIRECTION_NAMES]
IconName = Literal[*ICONS]
class ServiceItem(BaseModel):
name: S40 # 1 à 40 caractères
text: S160
icon: IconName # un nom du catalogue, pas une invention
tag: S24 | None = None
class ServicesSection(SectionBase):
type: Literal["services"]
variant: Literal[*variant_names("services")]
title: S60
items: list[ServiceItem] = Field(min_length=3, max_length=6)
Les bornes de longueur ne sont pas cosmétiques. Un titre de 60 caractères tient dans la maquette ; un titre de 140 caractères casse la grille sur mobile. Le typage devient ici la spécification du gabarit : si ça passe, ça rend correctement — c’est toute la valeur du catalogue fermé.
Même logique pour les champs qui vont par paire. Un badge dans le hero a un libellé et une valeur ; l’un sans l’autre n’a pas de sens :
if (self.badge_label is None) != (self.badge_value is None):
raise ValueError("badge : badge_label et badge_value vont ensemble, les deux ou aucun")
disponible · imposé · comptéavailable · forced · counted
pas de photo, pas de galerie ; pas de témoignage, pas de section témoignageno photo, no gallery; no testimonial, no testimonial section
directions · sections · variantes · icônesdirections · sections · variants · icons
le catalogue est engendré depuis le code : une seule source de véritéthe catalog is generated from the code: a single source of truth
tout optionnel, vide = absentall optional, empty = absent
absorbe les approximations du modèle sans casser la sortie structuréeabsorbs the model's approximations without breaking structured output
union discriminée + bornes + règlesdiscriminated union + bounds + rules
ce que le schéma ne dit pas — ordre, unicité, services du brief — est validé à la mainwhat the schema cannot say — order, uniqueness, brief services — is checked by hand
une ligne = un problèmeone line = one problem
les messages de refus repartent tels quels au modèle ; second échec = erreurthe rejection messages go back to the model as-is; a second failure is an error
une fois, pas deuxonce, not twiceCe que le schéma ne sait pas dire
Une partie des règles ne s’exprime dans aucun système de types raisonnable, parce qu’elles portent sur la séquence de sections plutôt que sur chacune :
if not 5 <= len(types) <= 8:
problems.append(f"5 à 8 sections attendues, {len(types)} reçues")
if types and types[0] != "hero":
problems.append("la première section doit être un hero")
if types and types[-1] != "contact":
problems.append("la dernière section doit être le contact")
if types.count("services") != 1:
problems.append("exactement une section services attendue")
for t in SECTION_NAMES:
if types.count(t) > 1:
problems.append(f"le type {t} apparaît au plus une fois")
if len(types) >= 2 and types[-2] == "cta-band":
problems.append("cta-band ne peut pas être en avant-dernière position, juste avant le contact")
La dernière est la plus intéressante : rien n’interdit techniquement une bande d’appel à l’action juste avant le formulaire de contact, mais le résultat est visuellement redondant — deux invitations à agir collées l’une à l’autre. C’est une règle de design, appliquée par le code, invisible du modèle jusqu’au moment où il la viole.
L’autre famille de règles dépend du brief, pas de la composition. Elle est portée par un contexte calculé en amont :
@dataclass(frozen=True)
class CompositionContext:
services: tuple[str, ...] # noms des services du brief, dans l'ordre
available: frozenset[str] # types de section disponibles pour ce brief
forced_direction: str | None
brand_ok: bool # kit de marque avec extraction réussie
photo_count: int
Trois vérifications en découlent, et la troisième est la plus importante du système :
- Pas de photo au brief, pas de section
gallery; pas de témoignage fourni, pas de sectiontestimonial. Le modèle ne peut pas inventer de la matière absente. - Autant de légendes que de photos, exactement.
- Les services du brief sont repris tous, dans l’ordre, avec les mêmes noms. La comparaison se fait sur les noms normalisés (espaces écrasés, casse repliée), et un écart est un refus :
got = [_squash(i.name) for i in s.items][:n]
if got != [_squash(x) for x in ctx.services]:
messages.append("services du brief à reprendre tous, dans l'ordre, avec les mêmes noms : "
+ ", ".join(ctx.services))
Sans cette règle, le modèle a une tendance nette à « améliorer » : renommer « Révision complète » en « Révision complète et réglages », ajouter un quatrième service plausible qui n’existe pas dans l’activité réelle. C’est exactement le type d’erreur qu’on ne veut pas découvrir en production, sur le site de quelqu’un.
Le retour d’erreur : une ligne = un problème
En cas de refus, les messages repartent au modèle pour une seule nouvelle tentative. Deux détails d’implémentation font toute la différence entre une reprise utile et une reprise décorative.
D’abord, les messages sont rédigés pour le modèle, pas pour un journal : « 5 à 8 sections attendues, 9 reçues » est actionnable, ValidationError at sections.8 ne l’est pas. Ils sont en français parce que le brief et les textes attendus le sont — inutile de faire basculer le modèle de langue au milieu d’une conversation.
Ensuite, un problème par ligne. Les erreurs de validation agrégées arrivent groupées dans un seul ValueError séparé par des sauts de ligne, et sont ré-éclatées à la sortie :
lines = msg.split("\n")
messages.append(f"{loc} : {lines[0]}" if loc else lines[0])
messages.extend(lines[1:])
Le modèle reçoit donc une liste de griefs distincts, pas un pavé. Sur ce cas d’usage, la reprise passe la seconde fois dans la grande majorité des refus observés — l’erreur typique est une section de trop ou un service reformulé, pas une incompréhension de fond.
Une seule reprise, et c’est fini. Un second échec marque la génération en erreur, visible dans l’interface. La tentation de boucler « jusqu’à ce que ça passe » est forte et mauvaise : elle transforme un bug de prompt en facture de tokens, et masque précisément le signal qu’on veut voir. Le pire cas est borné par construction : deux appels, sortie plafonnée à 10 000 jetons chacun.
Un détail qui n’en est pas un : ne rien réparer en silence
Une règle non écrite traverse tout le système : rien n’est corrigé silencieusement entre le brouillon et la forme stricte. Pas de troncature d’un titre trop long, pas de suppression de la section en trop, pas de substitution d’une icône inconnue par une icône par défaut.
C’est tentant — un title[:60] et le problème disparaît. Il disparaît surtout du champ de vision : le site part avec un titre coupé au milieu d’un mot, et personne ne saura jamais que le modèle avait mal compris la consigne. Un refus explicite, renvoyé au modèle, produit un meilleur site et un signal exploitable sur le prompt.
Le seul aménagement toléré va dans l’autre sens : la normalisation « chaîne vide → absent » à l’entrée du brouillon, qui ne répare pas une erreur mais traduit une convention.
Leçons retenues
Cinq habitudes réutilisables dès qu’un modèle alimente un système déterministe :
- Deux étages de schéma valent mieux qu’un. Un contrat permissif à la frontière du modèle, un contrat strict à l’entrée du domaine. On sépare ainsi « réponse mal formée » (à éliminer) de « réponse invalide » (à exploiter).
- Le catalogue engendre le prompt. Toute liste que le modèle doit respecter — types, variantes, noms d’icônes — se dérive du code qui l’implémente. Deux listes qui se ressemblent finissent toujours par diverger.
- Les bornes du typage sont la spécification du gabarit. Une longueur maximale n’est pas une coquetterie : c’est la promesse que ce qui passe se rend correctement.
- Les erreurs de validation sont un prompt. Écrites pour un lecteur, une ligne par problème, dans la langue de la tâche — elles valent bien mieux qu’une reformulation générique de la consigne.
- Une seule reprise, jamais de réparation silencieuse. La boucle infinie coûte cher et ment ; la correction discrète ment aussi, plus longtemps.
Ce qui reste au modèle, au bout du compte, tient en deux mots : choisir et rédiger. C’est peu, et c’est exactement ce qu’il fait le mieux.