Aller au contenu principal

Validation avec JSON Schema et Zod

Mis à jour le 28 juillet 2026

Validation avec JSON Schema et Zod

Le Structured Output garantit la structure de la réponse, mais pas la validité sémantique des données. Un champ “email” peut contenir une chaîne qui n’est pas un email valide. La validation côté client avec JSON Schema ou Zod est le complément indispensable pour une pipeline robuste.

Pourquoi valider après réception

Faites l’inventaire de ce que l’API vous garantit et le partage devient limpide. Le Structured Output d’OpenAI assure que le JSON est syntaxiquement valide, que les types annoncés sont respectés — une chaîne reste une chaîne, un entier reste un entier —, que tous les champs requis sont présents et que les valeurs contraintes par un enum appartiennent bien à la liste. Sur ces quatre points, vous pouvez écrire votre code sans défiance.

Tout le reste vous incombe, et cela couvre quatre familles de problèmes. Les contraintes de format échappent au schéma strict : rien n’empêche le modèle de renvoyer jean.dupont(at)exemple.fr dans un champ email, ou une date au format 15/03/2026 là où vous attendez de l’ISO. Les bornes ne sont pas davantage vérifiées, ni sur la longueur d’une chaîne, ni sur l’amplitude d’un nombre : un âge de 350 ans passera sans broncher. La cohérence entre champs reste hors de portée — une date_fin antérieure à la date_debut est un JSON parfaitement valide. Et la validité métier n’a aucune raison d’intéresser l’API : un SIRET doit faire quatorze chiffres, mais c’est votre domaine qui le sait, pas le décodeur.

Ces quatre familles ont un point commun redoutable : elles ne provoquent aucune erreur visible. Un email malformé enregistré en base ne se manifestera que le jour où la campagne d’envoi rebondit, des semaines plus tard. D’où la règle : on valide avant de persister, jamais après.

Validation Python avec jsonschema

La conséquence pratique est qu’il vous faut deux schémas pour la même donnée. Le premier part vers l’API, volontairement pauvre, limité à ce que le mode strict accepte. Le second reste chez vous et porte les contraintes fines — format, pattern, minimum, maximum. Cette duplication apparente est une séparation des responsabilités : l’un cadre la génération, l’autre défend votre stockage. Sur l’exemple ci-dessous, seul l’enum des rôles apparaît dans les deux, parce qu’il est la seule contrainte que les deux niveaux savent exprimer.

import json
from jsonschema import validate, ValidationError

# Schéma pour l'API OpenAI (strict, sans format/pattern)
api_schema = {
    "type": "object",
    "properties": {
        "email": {"type": "string"},
        "age": {"type": "integer"},
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]}
    },
    "required": ["email", "age", "role"],
    "additionalProperties": False
}

# Schéma étendu pour la validation client
validation_schema = {
    "type": "object",
    "properties": {
        "email": {
            "type": "string",
            "format": "email",
            "pattern": r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$"
        },
        "age": {
            "type": "integer",
            "minimum": 0,
            "maximum": 150
        },
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]}
    },
    "required": ["email", "age", "role"]
}

def valider_reponse(data: dict) -> tuple[bool, str]:
    """Valide la réponse du LLM avec le schéma étendu."""
    try:
        validate(instance=data, schema=validation_schema)
        return True, "Valide"
    except ValidationError as e:
        return False, f"Erreur : {e.message}"

Validation avec Pydantic

Pydantic est souvent plus pratique que jsonschema pour la validation en Python, et la différence tient à ce que vous obtenez en sortie. jsonschema vous dit oui ou non et vous laisse avec un dictionnaire, manipulé à coups de data["email"] sans aucune aide de l’éditeur. Pydantic vous rend un objet typé : l’autocomplétion fonctionne, une faute de frappe sur un nom d’attribut est détectée à l’écriture, et la donnée circule ensuite sans conversion.

Le modèle ContactExtraction exprime les bornes directement dans la déclaration des champs. Le field_validator sur date_extraction couvre le cas qu’aucune annotation de type ne peut attraper : la chaîne est bien une chaîne, encore faut-il qu’elle représente une date réelle — 2026-02-31 satisfait n’importe quel pattern raisonnable et n’existe pourtant pas. La fonction d’extraction montre l’enchaînement complet, du schéma appauvri envoyé à l’API jusqu’à l’instanciation validée.

from pydantic import BaseModel, Field, field_validator
from pydantic import EmailStr
from datetime import date

class ContactExtraction(BaseModel):
    """Schéma de validation pour l'extraction de contacts."""
    nom: str = Field(min_length=2, max_length=100)
    prenom: str = Field(min_length=2, max_length=100)
    email: EmailStr
    telephone: str = Field(pattern=r"^\+?[0-9\s\-\.]{10,15}$")
    entreprise: str
    poste: str
    date_extraction: str

    @field_validator("date_extraction")
    @classmethod
    def validate_date(cls, v):
        try:
            date.fromisoformat(v)
            return v
        except ValueError:
            raise ValueError(f"Date invalide : {v}")

def extraire_et_valider(texte: str) -> ContactExtraction:
    """Extrait un contact et valide les données."""
    # Schéma pour l'API (sans les contraintes fines)
    api_schema = {
        "type": "object",
        "properties": {
            "nom": {"type": "string"},
            "prenom": {"type": "string"},
            "email": {"type": "string"},
            "telephone": {"type": "string"},
            "entreprise": {"type": "string"},
            "poste": {"type": "string"},
            "date_extraction": {"type": "string"}
        },
        "required": ["nom", "prenom", "email", "telephone",
                      "entreprise", "poste", "date_extraction"],
        "additionalProperties": False
    }

    response = client.responses.create(
        model="gpt-5.6-terra",
        instructions="Extrais les informations de contact. "
                     "Format date : YYYY-MM-DD.",
        input=texte,
        text={
            "format": {
                "type": "json_schema",
                "name": "contact",
                "schema": api_schema,
                "strict": True
            }
        }
    )

    data = json.loads(response.output_text)
    # Validation Pydantic avec contraintes fines
    return ContactExtraction(**data)

Validation avec Zod (TypeScript/Node.js)

Si votre backend est en TypeScript, Zod est l’outil de référence, et il apporte un avantage que Pydantic n’a pas besoin de fournir : z.infer dérive le type TypeScript directement du schéma de validation. Vous décrivez la forme une seule fois, et le compilateur connaît ensuite la structure de Invoice sans que vous ayez maintenu une interface en parallèle, donc sans risque de divergence.

Le schéma de facture ci-dessous illustre la division du travail : côté API, siret n’est qu’une chaîne ; côté Zod, il doit faire exactement quatorze caractères et ne contenir que des chiffres. Le parse final lève une exception si la donnée ne convient pas, et c’est le comportement souhaitable — mieux vaut une erreur bruyante qui interrompt le traitement d’un document qu’une facture au SIRET fantaisiste rangée en base parmi dix mille autres.

import { z } from "zod";
import OpenAI from "openai";

const client = new OpenAI();

// Schéma Zod avec validations fines
const InvoiceSchema = z.object({
  numero: z.string().regex(/^[A-Z]{2}-\d{4}-\d{4}$/),
  date: z.string().date(),
  montant_ht: z.number().positive(),
  tva_taux: z.number().min(0).max(100),
  montant_ttc: z.number().positive(),
  fournisseur: z.object({
    nom: z.string().min(2),
    siret: z.string().length(14).regex(/^\d+$/),
  }),
});

type Invoice = z.infer<typeof InvoiceSchema>;

async function extractInvoice(text: string): Promise<Invoice> {
  const response = await client.responses.create({
    model: "gpt-5.6-terra",
    instructions: "Extrais les données de facture.",
    input: text,
    text: {
      format: {
        type: "json_schema",
        name: "invoice",
        schema: {
          type: "object",
          properties: {
            numero: { type: "string" },
            date: { type: "string" },
            montant_ht: { type: "number" },
            tva_taux: { type: "number" },
            montant_ttc: { type: "number" },
            fournisseur: {
              type: "object",
              properties: {
                nom: { type: "string" },
                siret: { type: "string" },
              },
              required: ["nom", "siret"],
              additionalProperties: false,
            },
          },
          required: ["numero", "date", "montant_ht",
                     "tva_taux", "montant_ttc", "fournisseur"],
          additionalProperties: false,
        },
        strict: true,
      },
    },
  });

  const data = JSON.parse(response.output_text);

  // Validation Zod avec contraintes fines
  return InvoiceSchema.parse(data);
}

Pattern : retry avec correction

Quand la validation échoue, renvoyez l’erreur au modèle pour qu’il corrige. Ce réflexe change la nature de l’échec : une date au mauvais format n’est plus une exception qui remonte jusqu’à l’utilisateur, mais un aller-retour supplémentaire de quelques centaines de millisecondes dont personne n’a conscience. Le mécanisme est simple — on réinjecte le message de validation dans le prompt de la tentative suivante, si bien que le modèle sait exactement ce qui n’allait pas au lieu de tirer une seconde fois au hasard.

Ce confort demande toutefois d’être encadré. Bornez le nombre de tentatives, faute de quoi une contrainte impossible à satisfaire — un pattern trop strict pour les numéros étrangers, par exemple — vous coûtera des appels en boucle. Et surveillez le taux de recours : si un quart de vos extractions passent par un retry, le problème est dans le prompt initial ou dans le schéma, pas dans le modèle.

def extract_with_retry(prompt: str, schema: dict,
                       validator, max_retries: int = 3) -> dict:
    """Extrait et valide avec retry automatique."""
    last_error = None

    for attempt in range(max_retries):
        input_text = prompt
        if last_error:
            input_text += (f"\n\nATTENTION : ta réponse précédente avait "
                          f"cette erreur de validation : {last_error}\n"
                          f"Corrige et réessaie.")

        response = client.responses.create(
            model="gpt-5.6-terra",
            input=input_text,
            text={"format": {"type": "json_schema",
                             "name": "data",
                             "schema": schema,
                             "strict": True}},
            temperature=0.1
        )

        data = json.loads(response.output_text)
        is_valid, error = validator(data)

        if is_valid:
            return data
        last_error = error

    raise ValueError(f"Échec après {max_retries} tentatives : {last_error}")

Un banc d’essai sur des invitations d’agenda

Construisez un schéma Pydantic pour extraire des événements — titre, date, heure, lieu, participants — puis branchez-le derrière un appel en Structured Output. Testez sur cinq emails contenant des invitations, en veillant à en inclure au moins un où l’heure est écrite en toutes lettres (« jeudi en fin de matinée ») et un où le lieu est implicite (« comme d’habitude en salle du conseil »). Ce sont ces cas-là qui font échouer la validation, et ils dominent largement dans une boîte mail réelle.

Ajoutez ensuite le retry automatique et mesurez deux chiffres : le taux de succès au premier essai et le taux après retry. Le second vous dit ce que la correction rapporte, le premier s’il faut retravailler vos instructions plutôt que compter sur la seconde chance.

Points clés à retenir

  • Le Structured Output garantit la structure, pas la validité sémantique
  • Utilisez deux schémas : un pour l’API (strict), un pour la validation client
  • Pydantic (Python) et Zod (TypeScript) sont les outils de référence
  • Le pattern retry avec feedback corrige la plupart des erreurs de validation
  • Validez toujours côté client avant de persister les données