3. Structured Outputs : Sorties JSON Garanties

# Parameters
BATCH_MODE = "true"

Navigation : Index | << Précédent | Suivant >> | Approfondissement >>

Structured Outputs : Sorties JSON Garanties avec OpenAI

Dans ce notebook, nous explorons les Structured Outputs, une fonctionnalité qui garantit que le modèle retourne des données JSON valides selon un schéma prédéfini.

Objectifs : - Comprendre la différence entre JSON mode et Structured Outputs - Maîtriser response_format avec JSON Schema - Intégrer Pydantic pour une validation type-safe - Gérer les erreurs de schéma

Prérequis : Notebook 1 (OpenAI Intro)

Durée estimée : 55 minutes

Navigation : << Précédent | Index | Suivant >> | Approfondissement >>

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Comprendre la différence entre JSON mode et Structured Outputs 2. Maîtriser response_format avec JSON Schema 3. Integrer Pydantic pour une validation type-safe 4. Gérer les erreurs de schema

Prerequis

  • Notebook 1 (OpenAI Intro)
  • Python 3.10+
  • Cle API OpenAI configuree

Duree estimee : 55 minutes


Structured Outputs : Sorties JSON Garanties avec OpenAI

# Verification des dependances (import guards)
try:
    from openai import OpenAI
    openai_AVAILABLE = True
except ImportError:
    openai_AVAILABLE = False
    print(f'WARNING: openai non installe. Installez avec: pip install openai')

try:
    from dotenv import load_dotenv
    dotenv_AVAILABLE = True
except ImportError:
    dotenv_AVAILABLE = False
    print(f'WARNING: python-dotenv non installe. Installez avec: pip install python-dotenv')

try:
    from pydantic import BaseModel, Field
    pydantic_AVAILABLE = True
except ImportError:
    pydantic_AVAILABLE = False
    print(f'WARNING: pydantic non installe. Installez avec: pip install pydantic')


# Installation des dépendances et configuration
from pathlib import Path
%pip install -q openai python-dotenv pydantic

import os
import json
from openai import OpenAI
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from typing import List, Optional

# Chargement robuste de la configuration .env
from dotenv import load_dotenv
import os
# Recherche du .env dans tous les parents (pour Papermill qui change le cwd)
current_path = Path.cwd()
env_loaded = False
for _ in range(10):
    env_path = current_path / ".env"
    if env_path.exists():
        load_dotenv(env_path)
        print(f".env charge depuis: {env_path.name}")
        env_loaded = True
        break
    if current_path.name == "GenAI" or len(current_path.parts) <= 1:
        break
    current_path = current_path.parent
if not env_loaded:
    print("WARNING: .env non trouve, utilisation variables environnement")
client = OpenAI()

# Charger le modèle depuis .env ou utiliser gpt-5-mini par défaut
DEFAULT_MODEL = os.getenv("OPENAI_MODEL", "gpt-5-mini")
BATCH_MODE = os.getenv("BATCH_MODE", "false").lower() == "true"

def add_additional_properties_false(schema: dict) -> dict:
    """
    Ajoute récursivement additionalProperties: False à tous les objets du schema
    ET s'assure que tous les champs sont dans required (requis par OpenAI).
    
    OpenAI Structured Outputs exige:
    1. additionalProperties: False sur tous les objets
    2. Tous les champs dans 'required' (même ceux avec defaults)
    """
    if not isinstance(schema, dict):
        return schema
    
    # Traiter les définitions $defs d'abord (Pydantic 2.x les génère)
    if "$defs" in schema:
        for def_name, def_schema in schema["$defs"].items():
            add_additional_properties_false(def_schema)
    
    # Si c'est un objet, ajouter additionalProperties: False
    # ET s'assurer que tous les champs sont dans required
    if schema.get("type") == "object":
        schema["additionalProperties"] = False
        # Ajouter tous les champs de properties dans required
        if "properties" in schema:
            all_props = list(schema["properties"].keys())
            schema["required"] = all_props
            # Traiter récursivement les propriétés
            for prop_name, prop_schema in schema["properties"].items():
                add_additional_properties_false(prop_schema)
    
    # Traiter les items d'arrays
    if schema.get("type") == "array" and "items" in schema:
        add_additional_properties_false(schema["items"])
    
    # Traiter anyOf, allOf, oneOf
    for key in ["anyOf", "allOf", "oneOf"]:
        if key in schema:
            for item in schema[key]:
                add_additional_properties_false(item)
    
    return schema

print("Client OpenAI initialisé !")
print(f"Modèle par défaut: {DEFAULT_MODEL}")
Note: you may need to restart the kernel to use updated packages.
.env charge depuis: .env
Client OpenAI initialisé !
Modèle par défaut: gpt-5-mini

1. JSON Mode vs Structured Outputs

OpenAI propose deux approches pour obtenir des sorties JSON :

JSON Mode

  • Garantit une sortie JSON valide (pas de syntaxe invalide)
  • NE garantit PAS la structure exacte du JSON
  • Le modèle peut inventer des clés, omettre des champs, etc.
  • Utilise response_format={"type": "json_object"}

Structured Outputs

  • Garantit une sortie JSON valide ET conforme au schéma
  • Le modèle respecte strictement le JSON Schema fourni
  • Pas de champs manquants, pas de types incorrects
  • Utilise response_format={"type": "json_schema", "json_schema": {...}}
  • Disponible depuis août 2024 (gpt-5-mini et versions ultérieures)

Cas d’usage : - JSON Mode : expérimentation rapide, structure flexible - Structured Outputs : production, insertion base de données, APIs strictes

Ancrage scholarly. Les Structured Outputs matérialisent l’idée de constrained / guided decoding — forcer le LLM à n’émettre que des tokens valides au regard d’un schéma. Voir Willard & Müller, Efficient Guided Generation for Large Language Models (2023), implémenté dans Outlines (dottxt-ai).

Démonstration : Limites du JSON Mode

Avant de voir les Structured Outputs, expérimentons avec le JSON Mode classique pour comprendre ses limites. Le modèle va générer du JSON valide, mais sans garantie sur la structure : les noms de clés, le niveau d’imbrication, et l’organisation des données sont laissés à sa discrétion.

# JSON mode basique (ancienne approche)
response = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=[{"role": "user", "content": "Liste 3 fruits avec leurs couleurs en JSON"}],
    response_format={"type": "json_object"}
)

result = json.loads(response.choices[0].message.content)
print("JSON Mode - Structure non garantie :")
print(json.dumps(result, indent=2, ensure_ascii=False))
print("\nNote : Le modèle a choisi la structure librement (clé 'fruits', sous-objets, noms de clés)")
JSON Mode - Structure non garantie :
{
  "fruits": [
    {
      "nom": "pomme",
      "couleur": "rouge"
    },
    {
      "nom": "banane",
      "couleur": "jaune"
    },
    {
      "nom": "raisin",
      "couleur": "violet"
    }
  ]
}

Note : Le modèle a choisi la structure librement (clé 'fruits', sous-objets, noms de clés)

Observation du JSON Mode

Le modèle a généré un JSON syntaxiquement valide, mais structure librement choisie :

Analyse de la sortie :

Élément Décision du modèle Problème potentiel
Clé racine "fruits" Pourrait être "items", "list", "data", etc. Impossible à prédire
Structure items Array d’objets avec name/color Pourrait être {"Banane": "Jaune", ...} (dict) ou [["Banane", "Jaune"], ...] (tuples)
Noms de clés name, color (anglais) Le prompt était en français, le modèle a choisi l’anglais
Nombre de fruits 3 (conforme à la demande) ✅ Respecté

Risques en production :

Si vous parsez ce JSON avec du code rigide :

for fruit in result["fruits"]:  # KeyError si le modèle choisit "items"
    print(fruit["name"])  # KeyError si le modèle choisit "nom"

Le code peut casser aléatoirement selon l’humeur du modèle ou des variations du prompt.

Cas d’usage acceptable pour JSON Mode : - Prototypage rapide où la structure exacte importe peu - Affichage direct à l’utilisateur sans parsing - Logging/debug

Quand passer aux Structured Outputs : - Insertion en base de données (schéma strict requis) - APIs avec contrats (clients attendent des champs précis) - Workflows multi-étapes (sortie d’une étape = entrée de la suivante)

2. Introduction aux Structured Outputs

Les Structured Outputs utilisent JSON Schema pour définir précisément la structure attendue.

Syntaxe :

response_format={
    "type": "json_schema",
    "json_schema": {
        "name": "nom_du_schema",
        "strict": True,  # Mode strict obligatoire
        "schema": {       # JSON Schema
            "type": "object",
            "properties": {...},
            "required": [...],
            "additionalProperties": False
        }
    }
}

Points clés : - strict: True est obligatoire pour les Structured Outputs - additionalProperties: False empêche les champs non définis - Tous les champs obligatoires doivent être dans required - Types supportés : string, number, integer, boolean, array, object, enum

Premier Exemple : Schéma Simple pour une Recette

Créons maintenant un schéma JSON strict pour forcer le modèle à respecter une structure précise. Ce premier exemple illustre les éléments essentiels d’un JSON Schema :

  • type : Type de données (object, array, string, etc.)
  • properties : Définition des champs de l’objet
  • required : Liste des champs obligatoires
  • additionalProperties: false : Interdit les champs non définis (requis par OpenAI)
  • enum : Restriction à un ensemble de valeurs autorisées

Note importante : OpenAI exige que additionalProperties soit false à tous les niveaux du schéma pour activer le mode strict.

# Structured Output avec schéma manuel
schema = {
    "type": "object",
    "properties": {
        "nom": {"type": "string"},
        "ingredients": {"type": "array", "items": {"type": "string"}},
        "temps_minutes": {"type": "integer"},
        "difficulte": {"type": "string", "enum": ["facile", "moyen", "difficile"]}
    },
    "required": ["nom", "ingredients", "temps_minutes", "difficulte"],
    "additionalProperties": False
}

response = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=[{"role": "user", "content": "Donne-moi une recette de crêpes."}],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "recette_schema",
            "strict": True,
            "schema": schema
        }
    }
)

recette = json.loads(response.choices[0].message.content)
print("Structured Output - Structure garantie :\n")
print(f"Recette: {recette['nom']}")
print(f"Difficulté: {recette['difficulte']}")
print(f"Temps: {recette['temps_minutes']} minutes")
print(f"Ingrédients: {', '.join(recette['ingredients'])}")
Structured Output - Structure garantie :

Recette: Crêpes classiques
Difficulté: facile
Temps: 30 minutes
Ingrédients: 250 g de farine, 3 œufs, 500 ml de lait, 2 cuillères à soupe de sucre (facultatif), 1 pincée de sel, 2 cuillères à soupe d'huile neutre ou 30 g de beurre fondu, 1 sachet de sucre vanillé ou 1 cuillère à café d'extrait de vanille (facultatif), Beurre pour la cuisson

Analyse du résultat

Comparons les sorties JSON Mode (cellule précédente) vs Structured Outputs :

Différences clés :

Aspect JSON Mode Structured Outputs
Clé racine "fruits" (inventée) "nom", "ingredients", etc. (imposées)
Noms de champs "name", "color" (anglais, au choix du modèle) Exactement ceux du schéma (français)
Champs garantis ❌ (le modèle peut omettre temps_minutes) ✅ Tous les champs de required présents
Type difficulte ❌ Pourrait être “simple” ou “easy” ✅ Limité à ["facile", "moyen", "difficile"] via enum
Validation automatique ❌ Nécessite parsing manuel ✅ JSON garanti conforme au schéma

Résultat obtenu : - Nom : “Crêpes Classiques” - Difficulté : “facile” (conforme à l’enum) - Temps : 30 minutes (type integer validé) - Ingrédients : Liste de strings (structure exacte du schéma)

Implications pratiques :

Sans Structured Outputs, vous devriez écrire :

if "nom" not in recette:
    raise ValueError("Champ 'nom' manquant")
if recette["difficulte"] not in ["facile", "moyen", "difficile"]:
    raise ValueError("Difficulté invalide")
# ... et ainsi de suite pour chaque champ

Avec Structured Outputs, tout cela est géré automatiquement par OpenAI. Le modèle ne peut physiquement pas retourner un JSON non conforme.

Exemple guidé : schéma JSON manuel pour un film

Contribution étudiante de @Daphneej91 (PR #18488), intégrée comme exemple guidé.

Sur le modèle de l’exemple de recette ci-dessus, ce schéma JSON manuel structure les informations d’un film : titre, année (entier), genre (énumération parmi “action”, “comedie”, “drame”, “science-fiction”, “thriller”), réalisateur, acteurs (liste de chaînes) et résumé — puis appelle l’API avec ce schéma.

Points clés : - Même structure que le schéma recette_schema ci-dessus - additionalProperties: False dans le schéma (requis par OpenAI) - Le champ annee est de type integer - "enum" contraint le genre aux 5 valeurs proposées - Le prompt de test : “Donne-moi les informations du film Interstellar”

# Exemple guide : Schema JSON manuel pour un film
# (contribution etudiante de @Daphneej91, PR #18488)

# Etape 1 : Definir le schema JSON manuellement
film_schema = {
    "type": "object",
    "properties": {
        "titre": {"type": "string"},
        "annee": {"type": "integer"},
        "genre": {"type": "string", "enum": ["action", "comedie", "drame", "science-fiction", "thriller"]},
        "realisateur": {"type": "string"},
        "acteurs": {"type": "array", "items": {"type": "string"}},
        "resume": {"type": "string"},
    },
    "required": ["titre", "annee", "genre", "realisateur", "acteurs", "resume"],
    "additionalProperties": False,
}

# Etape 2 : Appeler l'API avec le schema
response_film = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=[{"role": "user", "content": "Donne-moi les informations du film Interstellar"}],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "film_schema",
            "strict": True,
            "schema": film_schema,
        }
    }
)

# Etape 3 : Parser et afficher le resultat
film = json.loads(response_film.choices[0].message.content)
print(f"Film: {film['titre']} ({film['annee']})")
print(f"Genre: {film['genre']} | Realisateur: {film['realisateur']}")
print(f"Acteurs: {', '.join(film['acteurs'])}")
print(f"Resume: {film['resume']}")
Film: Interstellar (2014)
Genre: science-fiction | Realisateur: Christopher Nolan
Acteurs: Matthew McConaughey, Anne Hathaway, Jessica Chastain, Michael Caine, Matt Damon, Casey Affleck, Mackenzie Foy, Bill Irwin, Ellen Burstyn
Resume: Dans un futur proche où la Terre devient de moins en moins habitable, un ancien pilote de la NASA, Cooper, rejoint une mission interstellaire à travers un trou de ver près de Saturne pour trouver une nouvelle planète habitable. Le film explore la relativité du temps, les sacrifices personnels et la force des liens familiaux alors que l'équipe affronte des mondes hostiles et des dilemmes scientifiques. La découverte finale lie l'amour paternel et la physique, permettant à la fille de Cooper, Murph, de résoudre un problème crucial pour sauver l'humanité.

3. Intégration avec Pydantic

Écrire des JSON Schemas manuellement est verbeux et source d’erreurs. Pydantic simplifie cela :

Avantages : - Type safety : Validation automatique des types Python - Auto-complétion : IDE détecte les champs et types - Génération automatique : model_json_schema() crée le JSON Schema - Validation bidirectionnelle : model_validate_json() vérifie et convertit - Documentation : Field(description=...) enrichit le schéma

Workflow : 1. Définir les modèles Pydantic avec BaseModel 2. Générer le JSON Schema avec .model_json_schema() 3. Envoyer à l’API OpenAI 4. Valider la réponse avec .model_validate_json()

Exemple avec Objets Imbriqués

Passons à un modèle plus complexe avec des objets imbriqués (Ingredient) et des listes typées. Pydantic gère automatiquement la génération du JSON Schema pour ces structures complexes.

Points techniques : - List[Ingredient] génère un schéma pour un tableau d’objets structurés - Field(description=...) enrichit le schéma avec des descriptions que le modèle utilise comme contexte - La fonction add_additional_properties_false() est nécessaire car Pydantic ne génère pas cette propriété par défaut

Cette approche est beaucoup plus maintenable et type-safe que l’écriture manuelle de JSON Schema.

# Pydantic avec model_json_schema()
class Ingredient(BaseModel):
    nom: str = Field(description="Nom de l'ingrédient")
    quantite: str = Field(description="Quantité nécessaire")

class RecetteStructuree(BaseModel):
    nom: str = Field(description="Nom de la recette")
    description: str = Field(description="Description courte")
    ingredients: List[Ingredient] = Field(description="Liste des ingrédients")
    etapes: List[str] = Field(description="Étapes de préparation")
    temps_preparation: int = Field(description="Temps en minutes")
    difficulte: str = Field(description="Niveau de difficulté")

# Générer le schema et ajouter additionalProperties: False
schema = RecetteStructuree.model_json_schema()
schema = add_additional_properties_false(schema)

response = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=[{"role": "user", "content": "Donne-moi une recette de mousse au chocolat détaillée."}],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "recette_detaillee",
            "strict": True,
            "schema": schema
        }
    }
)

# Validation automatique avec Pydantic
recette = RecetteStructuree.model_validate_json(response.choices[0].message.content)
print(f"Recette: {recette.nom}")
print(f"Temps: {recette.temps_preparation} min | Difficulté: {recette.difficulte}")
print(f"\nDescription:\n{recette.description}")
print("\nIngrédients:")
for ing in recette.ingredients:
    print(f"  - {ing.nom}: {ing.quantite}")
print("\nÉtapes:")
for i, etape in enumerate(recette.etapes, 1):
    print(f"  {i}. {etape}")
Recette: Mousse au chocolat classique
Temps: 150 min | Difficulté: Facile

Description:
Mousse aérienne au chocolat noir, simple et rapide à préparer. Texture légère grâce aux blancs montés en neige, saveur intense de chocolat.

Ingrédients:
  - Chocolat noir (minimum 60% cacao): 200 g
  - Beurre (facultatif pour plus d'onctuosité): 20 g
  - Œufs (frais) - séparés: 4 (4 jaunes + 4 blancs)
  - Sucre: 30 g
  - Sel: 1 pincée
  - Extrait de vanille ou café soluble (facultatif): 1 cuillère à café

Étapes:
  1. Préparez le matériel : ramequins ou un grand saladier, un fouet, un saladier propre pour monter les blancs, et une casserole pour bain-marie.
  2. Cassez le chocolat en morceaux et faites-le fondre doucement au bain-marie avec le beurre (si utilisé). Remuez jusqu'à obtenir une texture lisse. Retirez du feu et laissez tiédir quelques minutes.
  3. Pendant ce temps, fouettez les jaunes d'œufs avec le sucre (et l'extrait de vanille ou le café si utilisé) jusqu'à ce que le mélange devienne légèrement pâle et un peu mousseux.
  4. Incorporez les jaunes sucrés au chocolat tiédi en mélangeant rapidement et délicatement pour homogénéiser.
  5. Dans un autre saladier parfaitement propre, montez les blancs d'œufs en neige ferme avec la pincée de sel. Ils doivent former des pics fermes mais sans être secs.
  6. Ajoutez une cuillerée ou deux de blancs montés au mélange chocolaté et incorporez vigoureusement pour détendre la préparation (cela facilite l'incorporation des autres blancs).
  7. Incorporez ensuite le reste des blancs en neige en trois fois, avec une spatule, en effectuant des mouvements enveloppants du bas vers le haut pour conserver l'aération. Ne pas trop mélanger.
  8. Répartissez la mousse dans des ramequins individuels ou versez-la dans un grand récipient. Lissez la surface si besoin.
  9. Placez au réfrigérateur au moins 2 heures (idéal 3-4 heures) pour que la mousse prenne et développe sa texture.
  10. Au moment de servir, vous pouvez décorer avec quelques copeaux de chocolat, cacao tamisé ou fruits rouges.
  11. Remarque sécurité : cette recette utilise des œufs crus. Utilisez des œufs très frais ou pasteurisés si vous avez des personnes à risque (femmes enceintes, jeunes enfants, personnes immunodéprimées). Conservez la mousse au réfrigérateur et consommez-la sous 48 heures.

Analyse du résultat

Le modèle a généré une recette complète et structurée en respectant parfaitement le schéma Pydantic :

Structure validée :

Champ Type Exemple Validation
nom str “Mousse au Chocolat Délicieuse” Non vide
description str “Une mousse aérienne…” Description concise
ingredients List[Ingredient] 5 ingrédients avec quantités Objets imbriqués
étapes List[str] 10 étapes numérotées Séquence logique
temps_preparation int 150 minutes Entier positif
difficulte str “Facile” Enum implicite

Avantages de Pydantic détectés :

  1. Auto-complétion IDE : recette.nom, recette.ingredients[0].quantite → détection automatique des champs
  2. Type safety : recette.temps_preparation est garanti être un int, pas une string
  3. Validation : Si le modèle retournait temps_preparation: "vingt", Pydantic lèverait une ValidationError
  4. Itération sécurisée : for ing in recette.ingredients → chaque ing est un objet Ingredient typé

Comparaison avec JSON manuel :

# Sans Pydantic (fragile)
recette_json = json.loads(response.content)
print(recette_json["nom"])  # Risque KeyError si clé manquante
temps = int(recette_json["temps"])  # Risque ValueError si type incorrect

# Avec Pydantic (robuste)
recette = RecetteStructuree.model_validate_json(response.content)
print(recette.nom)  # Garanti exister et être une string
temps = recette.temps_preparation  # Garanti être un int

Note : Le schéma Pydantic sert de contrat entre l’API et le code. Toute modification du modèle sera détectée par le type checker (mypy, Pylance).

Exemple guidé : extracteur d’événements avec contraintes réelles et validation croisée

Contribution étudiante de @Daphneej91 (PR #18488), intégrée comme exemple guidé.

Un Field(description=...) sur un champ str aide le modèle mais n’impose aucune valeur : sans contrainte de type, le validateur accepterait priorite="banane" et date="pas-une-date". Cet exemple définit un modèle EvenementExtrait qui contraint réellement ses valeurs :

  • Literal[...] (ensemble fermé) pour la priorité et le type d’événement ;
  • le type date pour date_evenement et date_reference ;
  • un field_validator qui rejette les champs texte vides ;
  • un model_validator qui vérifie la cohérence croisée des champs : un événement à venir ne peut pas être antérieur à la date de référence.

L’exemple instancie le modèle directement (sans appel LLM) : la validation croisée est ici purement Pydantic. La date de référence matérialise « aujourd’hui » fourni par l’appelant, comme recommandé lorsqu’un texte contient des expressions relatives (« lundi prochain »).

👉 L’exemple guidé qui suit redéfinit EvenementExtrait et complète avec trois observables supplémentaires : l’exposition des enums dans le JSON Schema, le rejet effectif des valeurs invalides, et la résolution des dates relatives contre une date_reference.

# Exemple guide : extracteur d'evenements avec contraintes reelles (contribution @Daphneej91, PR #18488)
from typing import List, Literal
from datetime import date
from pydantic import BaseModel, Field, field_validator, model_validator

# Modele Pydantic qui CONTRAINT reellement priorite, type et date
class EvenementExtrait(BaseModel):
    titre: str = Field(description="Titre de l'evenement")
    type_evenement: Literal["reunion", "deadline", "livraison", "presentation"]
    date_evenement: date = Field(description="Date absolue au format YYYY-MM-DD, resolue contre date_reference")
    priorite: Literal["haute", "moyenne", "basse"]
    date_reference: date = Field(description="Date du jour fournie pour resoudre les expressions relatives")
    participants: List[str] = Field(description="Personnes ou groupes impliques")
    description: str = Field(description="Description breve")

    @field_validator("titre", "description")
    @classmethod
    def non_vide(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("champ texte vide")
        return v.strip()

    @model_validator(mode="after")
    def date_coherente(self):
        # Un evenement a venir ne peut pas etre anterieur a la date du jour
        if self.date_evenement < self.date_reference:
            raise ValueError(
                f"date_evenement {self.date_evenement} anterieure a date_reference {self.date_reference}")
        return self

# Test avec un exemple : "Reunion de sprint lundi prochain a 14h avec l'equipe dev."
# date_reference = lundi 2026-03-30, donc "lundi prochain" = 2026-04-06
evenement = EvenementExtrait(
    titre="Reunion de sprint",
    type_evenement="reunion",
    date_evenement="2026-04-06",
    priorite="moyenne",
    date_reference="2026-03-30",
    participants=["equipe dev"],
    description="Reunion de sprint a 14h",
)
print(f"{evenement.date_evenement} | {evenement.type_evenement} | {evenement.priorite} | {evenement.titre}")
print(f"Participants: {', '.join(evenement.participants)}")
2026-04-06 | reunion | moyenne | Reunion de sprint
Participants: equipe dev

Exemple guidé : valider réellement la priorité, le type et la date

L’exemple guidé ci-dessus contraint déjà les valeurs (Literal, date) et vérifie la cohérence croisée des champs via un model_validator. Celui-ci redéfinit le même modèle (sans validateurs croisés, participants par défaut) pour montrer trois autres observables :

  1. Le JSON Schema exposer les enums (Literal) — contraintes réelles transmises au modèle ;
  2. Le valideur rejeter une priorité inconnue et une date non parsable (ce que Field(description=...) laissait passer) ;
  3. Comment résoudre une expression relative (« lundi prochain », « 30 mars ») contre une date_reference fournie explicitement, et signaler une expression ambiguë.

Extraction et résolution sont ici volontairement séparées : la résolution d’une date relative est un ajout pédagogique (déterministe, sans appel LLM), présenté avant la section 5 où extract_structured() fait l’appel réseau.

# Exemple guide : contraintes reelles (Literal + date), resolution des dates relatives
# Note : ce second exemple REDEFINIT EvenementExtrait (sans validateurs croises,
# participants par defaut) pour se concentrer sur le schema expose et les dates relatives.
from typing import List, Literal
from datetime import date, timedelta
from pydantic import BaseModel, Field, ValidationError

class EvenementExtrait(BaseModel):
    titre: str = Field(description="Titre de l'evenement")
    type_evenement: Literal["reunion", "deadline", "livraison", "presentation"]
    date_evenement: date = Field(description="Date au format YYYY-MM-DD, resolue contre date_reference")
    priorite: Literal["haute", "moyenne", "basse"]
    date_reference: date = Field(description="Date du jour fournie pour resoudre les expressions relatives")
    participants: List[str] = Field(default_factory=list, description="Personnes impliquees")
    description: str = Field(description="Description breve")

print("1. Le JSON schema expose les contraintes (enums) :")
schema = EvenementExtrait.model_json_schema()
print("   priorite :", schema["properties"]["priorite"]["enum"])
print("   type     :", schema["properties"]["type_evenement"]["enum"])
print("   date     :", schema["properties"]["date_evenement"]["type"], "/", schema["properties"]["date_evenement"]["format"])

print()
print("2. Le validateur REJETTE ce que Field(description=...) laissait passer :")
base = dict(titre="Reunion", type_evenement="reunion", date_evenement="2026-04-06",
            priorite="haute", date_reference="2026-03-30", description="Sprint planning")
try:
    EvenementExtrait(**{**base, "priorite": "banane"})
    print("   priorite 'banane' : ACCEPTE (probleme, aurait du rejeter)")
except ValidationError as e:
    print("   priorite 'banane' : REJETE ->", e.errors()[0]["loc"], e.errors()[0]["type"])
try:
    EvenementExtrait(**{**base, "date_evenement": "pas-une-date"})
    print("   date invalide     : ACCEPTE (probleme, aurait du rejeter)")
except ValidationError as e:
    print("   date invalide     : REJETE ->", e.errors()[0]["loc"], e.errors()[0]["type"])

print()
print("3. Resolution d'une expression relative contre une date_reference :")
JOURS = {"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3, "vendredi": 4, "samedi": 5, "dimanche": 6}
MOIS = {"janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,
        "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11, "decembre": 12}

def resoudre_date_relative(expr, ref):
    expr = expr.lower().strip()
    if "aujourd" in expr:
        return ref
    mots = expr.split()
    if len(mots) >= 2 and mots[1] == "prochain" and mots[0] in JOURS:
        jours = (JOURS[mots[0]] - ref.weekday()) % 7
        jours = 7 if jours == 0 else jours
        return ref + timedelta(days=jours)
    if mots and mots[0] in JOURS:
        return ref + timedelta(days=(JOURS[mots[0]] - ref.weekday()) % 7)
    if len(mots) >= 2 and mots[0].isdigit() and mots[1] in MOIS:
        try:
            return date(ref.year, MOIS[mots[1]], int(mots[0]))
        except ValueError:
            raise ValueError("date impossible : %s" % expr)
    raise ValueError("expression relative non resolvable (ambigu) : %s" % expr)

ref = date(2026, 3, 30)  # un lundi
print("   date_reference =", ref.isoformat())
for expr in ["lundi prochain", "vendredi", "30 mars", "le 30 mars"]:
    try:
        d = resoudre_date_relative(expr, ref)
        print("   %r -> %s" % (expr, d.isoformat()))
    except ValueError as err:
        print("   %r -> %s" % (expr, err))

print()
print("4. Modele valide avec la date resolue (champ date_evenement en type date) :")
ev = EvenementExtrait(titre="Reunion de sprint", type_evenement="reunion",
                      date_evenement=resoudre_date_relative("lundi prochain", ref),
                      priorite="haute", date_reference=ref,
                      participants=["Alice", "Bob"], description="Sprint planning")
print("   evenement valide :", ev.titre, "|", ev.date_evenement.isoformat(), "|", ev.priorite)
1. Le JSON schema expose les contraintes (enums) :
   priorite : ['haute', 'moyenne', 'basse']
   type     : ['reunion', 'deadline', 'livraison', 'presentation']
   date     : string / date

2. Le validateur REJETTE ce que Field(description=...) laissait passer :
   priorite 'banane' : REJETE -> ('priorite',) literal_error
   date invalide     : REJETE -> ('date_evenement',) date_from_datetime_parsing

3. Resolution d'une expression relative contre une date_reference :
   date_reference = 2026-03-30
   'lundi prochain' -> 2026-04-06
   'vendredi' -> 2026-04-03
   '30 mars' -> 2026-03-30
   'le 30 mars' -> expression relative non resolvable (ambigu) : le 30 mars

4. Modele valide avec la date resolue (champ date_evenement en type date) :
   evenement valide : Reunion de sprint | 2026-04-06 | haute

4. Cas d’Usage Avancés

Les Structured Outputs excellent dans les scénarios nécessitant une structure de données fiable :

Extraction de Données

  • Documents : CVs, factures, contrats
  • Emails : Extraction expéditeur, sujet, actions requises
  • Articles : Métadonnées (titre, auteur, date, mots-clés)

Génération de Formulaires

  • Questionnaires dynamiques : Générer des questions avec types de réponse
  • Configurations : Paramètres applicatifs structurés

Intégrations API

  • Réponses normalisées : Format uniforme pour frontend
  • Insertion base de données : Validation avant INSERT/UPDATE
  • Webhooks : Payload structuré pour intégrations tierces

Workflows Multi-Étapes

  • Agents : Chaque étape retourne une structure validée
  • Pipelines : Chaînage de transformations type-safe

Exemple Réaliste : Extraction de CV

L’extraction de données structurées depuis du texte non structuré est l’un des cas d’usage les plus puissants des Structured Outputs.

Défi technique : Le mode strict d’OpenAI impose que tous les champs soient dans required. Pour simuler des champs optionnels (comme email ou telephone qui peuvent être absents d’un CV), nous utilisons des valeurs par défaut ("" pour les strings) au lieu de Optional[str].

Cette approche permet de : - Garantir que le schéma est conforme aux exigences OpenAI - Gérer l’absence de certaines informations avec des valeurs par défaut - Maintenir la type safety Pydantic

Résultat attendu : Le modèle va parser le texte du CV et remplir automatiquement toutes les structures (expériences, formations, compétences) en respectant strictement le schéma Pydantic.

# Extraction structurée de CV
# Note: Pour Structured Outputs strict, tous les champs doivent être dans required.
# Les champs peuvent avoir des valeurs par défaut pour être "optionnels" en pratique.

class Experience(BaseModel):
    entreprise: str
    poste: str
    dates: str
    description: str = ""  # Valeur par défaut au lieu d'Optional

class Formation(BaseModel):
    etablissement: str
    diplome: str
    annee: int

class CVStructure(BaseModel):
    nom: str
    email: str = ""  # Valeur par défaut
    telephone: str = ""  # Valeur par défaut
    experiences: List[Experience]
    formations: List[Formation]
    competences: List[str]

texte_cv = """
Jean Dupont - jean.dupont@email.com - 06 12 34 56 78

EXPÉRIENCES:
- Développeur Senior chez TechCorp (2020-2023): Développement d'applications Python
- Développeur Junior chez StartupXYZ (2018-2020): Maintenance de bases de données

FORMATION:
- Master Informatique, Université Paris-Saclay, 2018
- Licence Informatique, Université Lyon 1, 2016

COMPÉTENCES: Python, SQL, Docker, Git, Machine Learning
"""

# Générer le schema et ajouter additionalProperties: False
schema = CVStructure.model_json_schema()
schema = add_additional_properties_false(schema)

response = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=[{"role": "user", "content": f"Extrais les informations de ce CV:\n\n{texte_cv}"}],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "cv_extraction",
            "strict": True,
            "schema": schema
        }
    }
)

cv = CVStructure.model_validate_json(response.choices[0].message.content)
print("=== CV STRUCTURÉ ===\n")
print(f"Candidat: {cv.nom}")
print(f"Contact: {cv.email} | {cv.telephone}")
print(f"\nExpériences ({len(cv.experiences)}):")
for exp in cv.experiences:
    print(f"  - {exp.poste} @ {exp.entreprise} ({exp.dates})")
    if exp.description:
        print(f"    -> {exp.description}")
print(f"\nFormations ({len(cv.formations)}):")
for form in cv.formations:
    print(f"  - {form.diplome} ({form.etablissement}, {form.annee})")
print(f"\nCompétences: {', '.join(cv.competences)}")
=== CV STRUCTURÉ ===

Candidat: Jean Dupont
Contact: jean.dupont@email.com | 06 12 34 56 78

Expériences (2):
  - Développeur Senior @ TechCorp (2020-2023)
    -> Développement d'applications Python
  - Développeur Junior @ StartupXYZ (2018-2020)
    -> Maintenance de bases de données

Formations (2):
  - Master Informatique (Université Paris-Saclay, 2018)
  - Licence Informatique (Université Lyon 1, 2016)

Compétences: Python, SQL, Docker, Git, Machine Learning

Analyse de l’extraction structurée

Le modèle a parfaitement parsé le CV non structuré en objets Pydantic typés :

Données extraites avec succès :

Catégorie Résultat Précision
Identité Nom, email, téléphone 100%
Expériences 2 postes avec dates et descriptions Chronologie respectée
Formations 2 diplômes avec établissements et années Ordre anti-chronologique
Compétences 5 compétences techniques Liste complète

Points techniques importants :

  1. Gestion des champs “optionnels” : Les champs email, telephone, description ont des valeurs par défaut "" au lieu d’être Optional[str]. Cela respecte l’exigence OpenAI que tous les champs soient dans required en mode strict.

  2. Objets imbriqués : List[Expérience] et List[Formation] sont correctement gérés. Le JSON Schema généré par Pydantic inclut des définitions d’objets imbriqués ($defs).

  3. Validation automatique : CVStructure.model_validate_json() garantit que chaque expérience a tous les champs requis et que les années sont bien des entiers.

Cas d’usage production : - ATS (Applicant Tracking System) : Parsing automatique de milliers de CVs - Matching candidat-poste : Extraction des compétences pour scoring - Anonymisation : Retrait automatique des données personnelles avant analyse RH - Base de données talents : Insertion directe en base avec schéma validé

5. Gestion des Erreurs

Même avec Structured Outputs, plusieurs types d’erreurs peuvent survenir :

Erreurs API

  • Quota dépassé : Limite de tokens ou requêtes
  • Modèle non supporté : Structured Outputs requiert gpt-5-mini+
  • Schéma invalide : JSON Schema mal formé ou trop complexe

Erreurs de Validation Pydantic

  • Type mismatch : Le modèle retourne un type incorrect (rare avec strict mode)
  • Contraintes non respectées : Field(ge=0, le=100) violé
  • Champs manquants : Champ requis absent (ne devrait pas arriver)

Bonnes Pratiques

  • Try/except : Toujours wrapper les appels API
  • Validation explicite : Utiliser model_validate_json() plutôt que json.loads()
  • Logging : Logger les erreurs pour debug
  • Fallback : Prévoir une stratégie de secours (retry, valeur par défaut)

Fonction Utilitaire Réutilisable

Pour éviter de répéter le même code de gestion d’erreurs et de génération de schéma, créons une fonction générique qui :

  1. Accepte n’importe quel modèle Pydantic
  2. Génère le JSON Schema adapté
  3. Gère les erreurs API et de validation
  4. Retourne une instance validée ou None

Cette fonction peut ensuite être réutilisée pour tous les cas d’extraction structurée du projet.

Test de la fonction : Évaluons un texte simple avec un modèle incluant des contraintes de validation (conint(ge=0, le=100) pour forcer un score entre 0 et 100).

# Gestion robuste des erreurs
from pydantic import ValidationError, conint

def extract_structured(prompt: str, model_class: type[BaseModel], model: str = None):
    """
    Fonction utilitaire pour extraction structurée avec gestion d'erreurs.

    Args:
        prompt: Prompt utilisateur
        model_class: Classe Pydantic pour validation
        model: Modèle OpenAI à utiliser (défaut: DEFAULT_MODEL)

    Returns:
        Instance validée de model_class ou None en cas d'erreur
    """
    if model is None:
        model = DEFAULT_MODEL
    
    try:
        # Générer le schema et ajouter additionalProperties: False (requis par OpenAI)
        schema = model_class.model_json_schema()
        schema = add_additional_properties_false(schema)

        response = client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            response_format={
                "type": "json_schema",
                "json_schema": {
                    "name": model_class.__name__.lower(),
                    "strict": True,
                    "schema": schema
                }
            }
        )
        return model_class.model_validate_json(response.choices[0].message.content)
    except ValidationError as e:
        print(f"Erreur de validation Pydantic: {e}")
        return None
    except Exception as e:
        print(f"Erreur API: {e}")
        return None

# Test de la fonction utilitaire
class Evaluation(BaseModel):
    score: conint(ge=0, le=100) = Field(description="Score de 0 à 100")
    commentaire: str = Field(description="Commentaire détaillé")
    points_forts: List[str] = Field(description="Points forts identifiés")
    points_amelioration: List[str] = Field(description="Points à améliorer")

eval_result = extract_structured(
    "Évalue ce texte: 'Python est un langage de programmation populaire.'",
    Evaluation
)

if eval_result:
    print("=== ÉVALUATION STRUCTURÉE ===\n")
    print(f"Score: {eval_result.score}/100")
    print(f"Commentaire: {eval_result.commentaire}")
    print("\nPoints forts:")
    for pf in eval_result.points_forts:
        print(f"  + {pf}")
    print("\nPoints d'amélioration:")
    for pa in eval_result.points_amelioration:
        print(f"  - {pa}")
else:
    print("Échec de l'extraction structurée")
=== ÉVALUATION STRUCTURÉE ===

Score: 85/100
Commentaire: Phrase correcte, claire et factuelle : elle affirme simplement que Python est un langage de programmation populaire. La formulation est grammaticalement juste et concise. Pour être plus informative et précise, on pourrait indiquer en quoi ou auprès de qui il est populaire (par ex. développeurs web, data science) ou apporter une source/mesure (classements, statistiques).

Points forts:
  + Grammaticalement correcte et bien ponctuée
  + Clarté et concision — le message est immédiatement compréhensible
  + Ton neutre et factuel

Points d'amélioration:
  - Trop générale — manque de contexte (pour quel public ou quel usage ?)
  - Aucune preuve ou précision (classement, domaines d'utilisation, exemples)
  - Formulation peu nuancée : «populaire» est vague ; on pourrait préciser le degré («très populaire») ou citer des sources

Interprétation des résultats

L’évaluation structurée démontre la puissance de la validation Pydantic combinée aux Structured Outputs :

Résultats obtenus : - Score : 88/100 (validé automatiquement dans la plage 0-100 grâce à conint) - Analyse qualitative : Le modèle a identifié que le texte est factuel mais manque de détails - Points forts : Concision, clarté de l’affirmation - Axes d’amélioration : Enrichissement avec exemples et justifications

Validation robuste :

La fonction extract_structured() encapsule les bonnes pratiques :

  1. Génération automatique du schéma : model_json_schema() évite l’écriture manuelle
  2. Ajout de additionalProperties: false : Requis par OpenAI strict mode
  3. Gestion des erreurs API : Timeouts, quotas, modèles invalides
  4. Validation Pydantic : model_validate_json() garantit type safety
  5. Retour gracieux : None en cas d’erreur au lieu d’exception non gérée

Avantage production : Cette fonction peut être réutilisée pour tous les cas d’extraction structurée du projet. Créer une librairie partagée avec des modèles Pydantic communs (feedback, facture, événement, etc.) accélère le développement.

6. Exemple Complet : Analyseur de Feedback Produit

Cas d’usage réaliste : analyser des feedbacks clients et les structurer pour analyse.

Objectifs : - Extraire sentiment, catégorie, priorité - Identifier les fonctionnalités mentionnées - Détecter les bugs signalés - Structurer pour insertion en base de données

Simulation de feedback client : Le modèle va analyser le texte, identifier automatiquement : - Le sentiment (ici mixte : positif sur l’appli, négatif sur le bug) - La catégorie (probablement bug_report) - La priorité (haute car bloquant pour le travail) - Les bugs et suggestions implicites

# Analyseur de feedback produit
class Bug(BaseModel):
    description: str = Field(description="Description du bug")
    severite: str = Field(description="Sévérité: critique, haute, moyenne, basse")

class FeedbackAnalyse(BaseModel):
    sentiment: str = Field(description="Sentiment général: positif, négatif, neutre, mixte")
    categorie: str = Field(description="Catégorie: feature_request, bug_report, question, praise, complaint")
    priorite: str = Field(description="Priorité: haute, moyenne, basse")
    resume: str = Field(description="Résumé en une phrase")
    fonctionnalites_mentionnees: List[str] = Field(description="Fonctionnalités citées")
    bugs_detectes: List[str] = Field(description="Bugs identifiés")
    suggestions: List[str] = Field(description="Suggestions d'amélioration")
    actions_recommandees: List[str] = Field(description="Actions à entreprendre")

feedback_client = """
J'adore l'application en général, mais le bouton d'export ne fonctionne pas depuis la dernière mise à jour.
J'ai essayé plusieurs fois et rien ne se passe. C'est vraiment frustrant car j'en ai besoin pour mon travail.
Sinon l'interface mobile pourrait être améliorée, elle est un peu confuse.
"""

analyse = extract_structured(
    f"Analyse ce feedback client et structure-le:\n\n{feedback_client}",
    FeedbackAnalyse
)

if analyse:
    print("=== ANALYSE DE FEEDBACK ===\n")
    print(f"Sentiment: {analyse.sentiment}")
    print(f"Catégorie: {analyse.categorie}")
    print(f"Priorité: {analyse.priorite}")
    print(f"\nRésumé:\n{analyse.resume}")
    print(f"\nFonctionnalités mentionnées:")
    for feat in analyse.fonctionnalites_mentionnees:
        print(f"  • {feat}")
    if analyse.bugs_detectes:
        print(f"\nBugs détectés:")
        for bug in analyse.bugs_detectes:
            print(f"  🐛 {bug}")
    if analyse.suggestions:
        print(f"\nSuggestions:")
        for sug in analyse.suggestions:
            print(f"  💡 {sug}")
    print(f"\nActions recommandées:")
    for action in analyse.actions_recommandees:
        print(f"  → {action}")
=== ANALYSE DE FEEDBACK ===

Sentiment: mixte
Catégorie: bug_report
Priorité: haute

Résumé:
Utilisateur apprécie l'application mais le bouton d'export ne fonctionne plus depuis la dernière mise à jour et l'interface mobile est jugée confuse.

Fonctionnalités mentionnées:
  • Bouton d'export
  • Interface mobile
  • Application (usage général)

Bugs détectés:
  🐛 Le bouton d'export est inopérant depuis la dernière mise à jour — l'utilisateur clique plusieurs fois et rien ne se passe

Suggestions:
  💡 Corriger le dysfonctionnement du bouton d'export en priorité
  💡 Améliorer la clarté et l'ergonomie de l'interface mobile

Actions recommandées:
  → Reproduire le bug sur les environnements de test avec la version de la dernière mise à jour
  → Récupérer les informations complémentaires auprès de l'utilisateur (version de l'app, OS, modèle de téléphone, étapes exactes, éventuels messages d'erreur)
  → Consulter les logs et ajouter du logging côté export pour identifier la cause
  → Déployer un correctif / hotfix pour le bouton d'export si la cause est critique
  → Prévoir une revue UX de l'interface mobile et prioriser des améliorations ergonomiques
  → Communiquer un message clair à l'utilisateur (accusé de réception, délai estimé de correction)

Interprétation de l’analyse de feedback

Le modèle a correctement identifié :

Dimension Valeur détectée Justification
Sentiment Mixte “J’adore” (positif) vs “frustrant” (négatif)
Catégorie bug_report Dysfonctionnement signalé explicitement
Priorité Haute Impact métier direct (“besoin pour mon travail”)
Bug principal Bouton export non fonctionnel Depuis dernière mise à jour
Suggestion Amélioration interface mobile Point secondaire mais identifié

Actions extraites automatiquement : 1. Corriger le bug d’export (prioritaire) 2. Planifier amélioration UX mobile

Ce type de structuration permet : - Tri automatique par priorité dans un système de ticketing - Calcul de métriques (distribution sentiments, catégories) - Routing intelligent vers les équipes (dev vs UX vs support) - Alimentation d’un dashboard produit en temps réel

Note production : En contexte réel, ajouter une validation humaine pour les feedbacks haute priorité avant création automatique de tickets.

Exemple guidé : analyseur de produits e-commerce

Contribution étudiante de @Daphneej91 (PR #18488), intégrée comme exemple guidé.

Cet exemple extrait et structure les informations d’une fiche produit e-commerce, avec des objets imbriqués (caractéristiques techniques, avis clients) et des contraintes de validation. Le schéma ProduitAnalyse est défini avec Pydantic, puis les données sont extraites d’une description produit non structurée.

Points clés : - La fonction extract_structured() déjà définie dans ce notebook fait l’appel API et la validation - Le modèle inclut : nom, prix (confloat(gt=0)), catégorie, liste de caractéristiques (nom + valeur), note moyenne (0-5 avec confloat), et liste d’avis (auteur, commentaire, note conint(ge=1, le=5)) - Texte de test (légèrement enrichi d’un prix par rapport à l’énoncé d’origine) : “MacBook Pro M4 - 1999 EUR. Ordinateur portable Apple avec puce M4 Pro, 18Go RAM, 512Go SSD. Ecran Liquid Retina XDR 14 pouces. Note : 4.7/5. Avis : ‘Excellent pour le dev’ par Alice (5/5), ‘Un peu cher’ par Bob (4/5).”

# Exemple guide : Analyseur de produits e-commerce
# (contribution etudiante de @Daphneej91, PR #18488)

# Etape 1 : Definir les modeles Pydantiques imbriques
from pydantic import confloat
from pydantic import BaseModel, Field, confloat, conint

class Caracteristique(BaseModel):
    nom: str = Field(description="Nom de la caracteristique")
    valeur: str = Field(description="Valeur de la caracteristique")

class Avis(BaseModel):
    auteur: str = Field(description="Nom de l'auteur de l'avis")
    commentaire: str = Field(description="Contenu de l'avis")
    note: conint(ge=1, le=5) = Field(description="Note sur 5")

class ProduitAnalyse(BaseModel):
    nom: str = Field(description="Nom du produit")
    prix: confloat(gt=0) = Field(description="Prix en euros")
    categorie: str = Field(description="Catégorie du produit")
    caracteristiques: list[Caracteristique] = Field(description="Caractéristiques")
    note_moyenne: confloat(ge=0, le=5) = Field(description="Note moyenne sur 5")
    avis: list[Avis] = Field(description="Avis clients")

# Etape 2 : Tester avec un texte de produit
texte_produit = (
    "MacBook Pro M4 - 1999 EUR. Ordinateur portable Apple avec puce M4 Pro, "
    "18Go RAM, 512Go SSD. Ecran Liquid Retina XDR 14 pouces. Note : 4.7/5. "
    "Avis : 'Excellent pour le dev' par Alice (5/5), 'Un peu cher' par Bob (4/5)."
)
produit = extract_structured(f"Extrais les infos de ce produit:\n{texte_produit}", ProduitAnalyse)

# Etape 3 : Afficher le resultat structure
if produit:
    print(f"Produit: {produit.nom} ({produit.prix} EUR) note : {produit.note_moyenne}")
    print("Caractéristiques :")
    for carac in produit.caracteristiques:
        print(f"  {carac.nom}: {carac.valeur}")
    print("Avis :")
    for a in produit.avis:
            print(f"  {a.auteur}: {a.note}/5 : {a.commentaire}")
Produit: MacBook Pro M4 (1999.0 EUR) note : 4.7
Caractéristiques :
  Marque: Apple
  Puce: M4 Pro
  RAM: 18 Go
  Stockage: 512 Go SSD
  Écran: Liquid Retina XDR 14 pouces
Avis :
  Alice: 5/5 : Excellent pour le dev
  Bob: 4/5 : Un peu cher

7. Comparaison des Approches

Récapitulatif des différentes méthodes pour obtenir des sorties structurées :

# Tableau comparatif
print("╔════════════════════════════════════════════════════════════════════════════════════╗")
print("║                      COMPARAISON DES APPROCHES JSON                                ║")
print("╠════════════════════════════════════════════════════════════════════════════════════╣")
print("║ Approche              │ JSON valide │ Structure garantie │ Type-safe │ Complexité ║")
print("╠════════════════════════════════════════════════════════════════════════════════════╣")
print("║ Prompt classique      │      ❌     │         ❌         │     ❌    │   Faible   ║")
print("║ JSON mode             │      ✅     │         ❌         │     ❌    │   Faible   ║")
print("║ JSON Schema manuel    │      ✅     │         ✅         │     ❌    │   Élevée   ║")
print("║ Pydantic + Structured │      ✅     │         ✅         │     ✅    │   Moyenne  ║")
print("╚════════════════════════════════════════════════════════════════════════════════════╝")
print("\nRecommandations :")
print("  • Prototypage rapide → JSON mode")
print("  • Production / APIs → Pydantic + Structured Outputs")
print("  • Insertion BDD → Pydantic + Structured Outputs")
print("  • Workflows complexes → Pydantic + Structured Outputs")
╔════════════════════════════════════════════════════════════════════════════════════╗
║                      COMPARAISON DES APPROCHES JSON                                ║
╠════════════════════════════════════════════════════════════════════════════════════╣
║ Approche              │ JSON valide │ Structure garantie │ Type-safe │ Complexité ║
╠════════════════════════════════════════════════════════════════════════════════════╣
║ Prompt classique      │      ❌     │         ❌         │     ❌    │   Faible   ║
║ JSON mode             │      ✅     │         ❌         │     ❌    │   Faible   ║
║ JSON Schema manuel    │      ✅     │         ✅         │     ❌    │   Élevée   ║
║ Pydantic + Structured │      ✅     │         ✅         │     ✅    │   Moyenne  ║
╚════════════════════════════════════════════════════════════════════════════════════╝

Recommandations :
  • Prototypage rapide → JSON mode
  • Production / APIs → Pydantic + Structured Outputs
  • Insertion BDD → Pydantic + Structured Outputs
  • Workflows complexes → Pydantic + Structured Outputs

Exercices à compléter

Les trois exercices suivants mesurent des comportements qu’aucun exemple guidé de ce notebook ne couvre : champs absents ou inventés, natures d’objets mélangées dans une même liste, et taux de réussite sur des entrées variées. Ils réutilisent le client, la fonction extract_structured() et les contraintes Pydantic vus plus haut.

Exercice 1 : Champs optionnels en mode strict — information absente ou inventée ?

Tous les schémas de ce notebook utilisent des champs tous obligatoires. En mode strict, OpenAI impose que chaque champ figure dans required, mais un champ peut rester nullable : son type devient anyOf: [{"type": "string"}, {"type": "null"}]. La question que cet exercice mesure : quand l’information est absente du texte, le modèle renvoie-t-il null ou invente-t-il une valeur plausible ?

Objectif : définir un modèle ContactExtrait avec des champs obligatoires (nom, telephone) et des champs optionnels (email, entreprise, ville en Optional[str] = None), l’appliquer à deux textes — l’un complet, l’autre où certaines informations manquent — et vérifier champ par champ que les informations absentes valent None, pas une valeur hallucinée.

Indices : - Optional[str] = None produit exactement le anyOf string/null accepté par le mode strict - Précisez dans chaque Field(description=...) : « null si l’information n’apparaît pas dans le texte » — sans cette consigne, le modèle n’a aucune raison de choisir null - extract_structured() renvoie l’instance validée ou None en cas d’échec - Comparez le nombre de champs renseignés entre le texte complet et le texte lacunaire, et signalez toute valeur qui n’apparaît nulle part dans le texte source

# Exercice 1 : Champs optionnels en mode strict -- information absente ou inventee ?
# TODO etudiant : definir ContactExtrait et mesurer champs absents vs inventes

# Etape 1 : Definir le modele (champs requis ET optionnels)
# TODO etudiant : class ContactExtrait(BaseModel) avec nom et telephone requis,
#   puis email, entreprise et ville en Optional[str] = None
#   (description de chaque champ optionnel : "null si l'information n'apparait pas dans le texte")

# Etape 2 : Extraire depuis un texte complet puis un texte lacunaire
texte_complet = ("Bonjour, je m'appelle Ines Benali, joignable au 06 98 76 54 32. "
                 "Email : ines.benali@exemple.fr, societe Atelier Numerique, Lyon. "
                 "Je souhaite un devis.")
texte_lacunaire = ("Bonjour, je m'appelle Ines Benali, joignable au 06 98 76 54 32. "
                   "Je souhaite un devis.")
# TODO etudiant : appeler extract_structured(...) sur les deux textes avec ContactExtrait

# Etape 3 : Comparer champ par champ
# TODO etudiant : pour chaque champ optionnel, afficher sa valeur (ou None) pour les deux textes,
#   compter les champs renseignes dans chaque cas, et signaler toute valeur inventee
#   (une valeur qui n'apparait nulle part dans le texte source est une hallucination)

resultat_complet = None  # TODO etudiant
resultat_lacunaire = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer

Exercice 2 : Union de types — des objets de natures différentes dans une même liste

Chaque liste de ce notebook contient des objets homogènes (des Ingredient, des Avis, des Caracteristique). Un journal de modifications mélange des entrées de natures différentes : fonctionnalités, correctifs, dépréciations. Structurer cette hétérogénéité demande une union de types : List[Union[A, B, C]] produit dans le schéma un items avec anyOf — une branche par nature d’objet.

Objectif : définir trois modèles Pydantic (NouvelleFonctionnalite, CorrectionBug, Deprecation), chacun portant un champ type: Literal[...] distinct (“feature”, “bugfix”, “deprecation”), les réunir dans un modèle Changelog dont le champ entrees est une liste de cette union, extraire le changelog ci-dessous, puis vérifier que chaque élément est validé dans la bonne classe (isinstance) et compter chaque nature.

Indices : - Chaque branche doit porter un champ type avec un Literal différent : c’est lui qui garantit qu’une seule branche peut valider un objet donné - Le anyOf généré est accepté par le mode strict ; la fonction add_additional_properties_false() (utilisée par extract_structured) ajoute additionalProperties: False dans chaque branche - Après model_validate_json, Pydantic rend pour chaque élément l’instance de la bonne classe — vérifiez avec isinstance(entree, NouvelleFonctionnalite) etc. - Comptez les natures (par exemple avec collections.Counter) et confrontez au texte source : 2 fonctionnalités, 2 correctifs, 1 dépréciation

Changelog de test : “Version 2.3.0 — Nouvelle fonctionnalité : export PDF (module Rapports). Correction : crash au démarrage sur Windows, sévérité critique. Dépréciation : l’API v1, remplacée par l’API v2. Nouvelle fonctionnalité : thème sombre (module Interface). Correction : faute de frappe dans les paramètres, sévérité mineure.”

# Exercice 2 : Union de types -- des objets de natures differentes dans une meme liste
# TODO etudiant : definir les trois modeles + l'union, extraire et verifier les classes

# Etape 1 : Definir les trois modeles, chacun avec un champ type: Literal[...] distinct
# TODO etudiant : NouvelleFonctionnalite (type "feature", titre, module),
#   CorrectionBug (type "bugfix", titre, severite Literal["critique", "majeure", "mineure"]),
#   Deprecation (type "deprecation", element, remplace_par)

# Etape 2 : Definir Changelog (version + entrees en List[Union[...]]) et extraire
changelog_texte = ("Version 2.3.0 - Nouvelle fonctionnalite : export PDF (module Rapports). "
                   "Correction : crash au demarrage sur Windows, severite critique. "
                   "Deprecation : l'API v1, remplacee par l'API v2. "
                   "Nouvelle fonctionnalite : theme sombre (module Interface). "
                   "Correction : faute de frappe dans les parametres, severite mineure.")
# TODO etudiant : appeler extract_structured(...) avec le modele Changelog

# Etape 3 : Verifier la classe de chaque entree et compter les natures
# TODO etudiant : pour chaque entree, verifier isinstance(...) puis compter les natures
#   (collections.Counter) et confronter le decompte au texte source

changelog = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer

Exercice 3 : Robustesse — taux de réussite d’une extraction sur N textes variés

Les exemples de ce notebook montrent des extractions réussies. En production, la vraie question est mesurable : sur N entrées variées, combien respectent le schéma et les contraintes ? extract_structured() rend déjà None en cas d’échec (erreur API ou ValidationError Pydantic) — il suffit de compter.

Objectif : définir un modèle CritiqueExtraite (sentiment contraint par Literal["positif", "negatif", "neutre", "mixte"], note contrainte par conint(ge=0, le=5)), l’appliquer à une liste de 6 à 8 textes volontairement hétérogènes (très court, familier, ambivalent, hors sujet, très long), compter les succès et les échecs, et afficher le taux de réussite.

Indices : - Réutilisez extract_structured() tel quel : chaque appel renvoie l’instance validée ou None - C’est la variété des textes qui rend la mesure intéressante : incluez au moins un texte sans aucune opinion (que renvoie le modèle pour le sentiment ?) et un texte très long - succes = sum(1 for r in resultats if r is not None), puis affichez le pourcentage - Pour chaque échec, notez la cause visible dans la sortie de extract_structured (erreur de validation vs erreur API)

# Exercice 3 : Robustesse -- taux de reussite d'une extraction sur N textes varies
# TODO etudiant : mesurer le taux de reussite d'une extraction contrainte sur N textes

# Etape 1 : Definir le modele contraint
# TODO etudiant : class CritiqueExtraite(BaseModel) avec
#   sentiment: Literal["positif", "negatif", "neutre", "mixte"] et note: conint(ge=0, le=5)

# Etape 2 : Preparer une liste de 6 a 8 textes heterogenes
# TODO etudiant : textes = [...] avec au minimum un texte tres court, un texte familier,
#   un texte ambivalent, un texte HORS SUJET (aucune opinion) et un texte tres long

# Etape 3 : Extraire, compter succes et echecs, afficher le taux
# TODO etudiant : boucler avec extract_structured(...), compter les resultats None,
#   afficher le taux de reussite en pourcentage et la cause de chaque echec

resultats = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer

Conclusion et Exercices

Résumé

Les Structured Outputs d’OpenAI révolutionnent l’intégration LLM en production :

✅ Fiabilité : Garantie de conformité au schéma (plus de parsing fragile)
✅ Type Safety : Pydantic apporte validation et auto-complétion
✅ Productivité : Moins de code de validation manuelle
✅ Maintenabilité : Schémas centralisés et réutilisables

Points clés à retenir : - strict: True est obligatoire pour Structured Outputs - Pydantic simplifie drastiquement la définition de schémas - Toujours valider avec .model_validate_json() pour type safety - Gérer les erreurs API et de validation explicitement


Pour aller plus loin

Pour aller plus loin A : Extracteur d’Événements (Débutant)

Créer un modèle Pydantic pour extraire des événements d’un texte : - Titre de l’événement - Date et heure - Lieu - Participants (liste)

Tester avec : “Réunion d’équipe le 15 mars 2025 à 14h30 dans la salle A. Participants : Alice, Bob, Charlie.”

Pour aller plus loin B : Analyseur de Factures (Intermédiaire)

Définir un schéma pour extraire : - Numéro de facture - Date d’émission - Fournisseur (nom, adresse) - Articles (nom, quantité, prix unitaire, total) - Montant total TTC

Générer une fausse facture avec GPT et extraire les données.

Pour aller plus loin C : Générateur de Quiz (Avancé)

Créer un système qui : 1. Génère un quiz sur un sujet donné (5 questions) 2. Chaque question a 4 choix et une réponse correcte 3. Inclut une explication pour chaque réponse 4. Valide que la réponse correcte est bien dans les choix

Bonus : Ajouter des contraintes Pydantic (ex: @field_validator)


Ressources


Prochaine étape : Notebook 4 - Function Calling et Agents

Retour au sommet