JSON fiable avec ChatGPT: schéma strict + Zod + auto-réparation en Node.js
Assurez un JSON valide avec ChatGPT: schéma strict, validation Zod côté serveur et auto-réparation en Node.js. Tutoriel concret, prêt pour la prod.
Sommaire
JSON fiable avec ChatGPT: schéma strict + Zod + auto-réparation en Node.js
Un modèle LLM peut “tenter” de retourner du JSON, mais ce n’est pas un contrat robuste. Ce tutoriel montre comment forcer une sortie JSON stricte en s’appuyant sur un schéma, valider au runtime avec Zod côté serveur, et implémenter une boucle d’auto-réparation en cas d’écart. Le résultat est un pipeline Node.js prêt pour la production, conçu pour extraire un ticket support structuré à partir d’un email libre.
Objectif
L’objectif est de mettre en place un pipeline qui impose un format JSON valide et conforme à un schéma défini, tout en validant côté serveur avec Zod pour ne jamais faire confiance à la sortie brute. En cas d’invalidité, une boucle de réessai courte et guidée par les erreurs tente de réparer automatiquement la réponse. À la fin, vous disposerez d’un tutoriel concret qui fonctionne en production avec un contrat versionné, une validation stricte et des garde-fous sur les coûts et la robustesse.
Objectif et cas d’usage: extraire un ticket support structuré
Nous allons convertir un email de support libre en un JSON normalisé avec les champs intent, priority, customer_id, due_date et actions. Par exemple, un email “Bonjour, notre intégration Stripe échoue depuis hier, c’est urgent, client ACME-42.” doit devenir un objet strictement typé où intent vaut bug, priority vaut high, customer_id vaut ACME-42, due_date peut être nulle si elle n’est pas donnée, et actions contient des étapes proposées comme “contacter le client”, “vérifier les logs Stripe”.
Le critère de succès est triple. D’abord, le JSON doit être parsable sans erreurs et strictement conforme au schéma. Ensuite, la validation Zod côté serveur doit passer avec moins de 1% d’échecs en exploitation réelle, ce qui impose des prompts stricts, des descriptions claires et une boucle de réparation. Enfin, le coût doit rester maîtrisé en privilégiant un modèle extraction-friendly comme gpt-4o-mini avec temperature faible et en ajoutant une surveillance simple des tokens consommés.
Setup rapide du projet
Commencez par initialiser un projet Node 20+ avec TypeScript. Vous pouvez créer un dossier, initialiser npm, ajouter TypeScript, et préparer un tsconfig minimal pour la compilation. Installez ensuite les dépendances nécessaires: openai pour les appels LLM, zod pour la validation runtime, dotenv pour charger la clé API depuis un fichier .env, ainsi que des utilitaires typiques comme cross-fetch si nécessaire dans certains environnements.
Exécutez les commandes suivantes pour mettre en place la base du projet:
mkdir json-llm-strict && cd json-llm-strict
npm init -y
npm install openai zod dotenv
npm install -D typescript ts-node @types/node
npx tsc --init --target ES2020 --module ES2020 --moduleResolution node --outDir dist --rootDir src
Créez un fichier .env à la racine pour votre clé API et assurez-vous qu’il n’est pas commité en ajoutant .env à votre .gitignore. Par exemple:
echo "OPENAI_API_KEY=sk-your-key" > .env
echo ".env" >> .gitignore
Préparez une structure minimale adaptée à notre tutoriel avec trois fichiers TypeScript dans src: schema.ts, llm.ts et index.ts. Le fichier schema.ts va contenir le contrat JSON Schema et son miroir Zod, llm.ts s’occupera de l’appel au modèle, du parsing et de la réparation, et index.ts fournira un exemple d’utilisation exécutable en ligne de commande.
Définir le contrat: schéma JSON et Zod
Commencez par écrire un JSON Schema minimal et versionné pour un Ticket v1. L’idée est d’être strict sur les champs obligatoires, explicite sur null vs optional, et d’introduire des enums pour guider le modèle. Miroitez ensuite ce contrat avec Zod pour valider au runtime, avec des règles conditionnelles utiles comme “si la priorité est high, alors due_date ne doit pas être nulle” si cela correspond à votre règle métier.
Voici un exemple complet dans src/schema.ts:
// src/schema.ts
import { z } from "zod";
// JSON Schema v1 pour l'extraction stricte côté LLM
export const ticketJsonSchemaV1 = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://example.com/schemas/ticket.v1.json",
title: "Support Ticket v1",
type: "object",
additionalProperties: false,
properties: {
schema_version: {
const: "v1",
description: "Version immuable du schéma pour l'objet retourné."
},
customer_id: {
type: ["string", "null"],
pattern: "^[A-Za-z0-9._-]+{{content}}quot;,
description: "Identifiant client s'il est présent. Exemple: ACME-42. Peut être null si inconnu."
},
intent: {
type: "string",
enum: ["question", "bug", "feature_request", "billing", "other"],
description: "Catégorie principale du ticket extraite du message."
},
priority: {
type: "string",
enum: ["low", "medium", "high"],
description: "Niveau d'urgence déduit du contenu. Utilisez high seulement si explicite."
},
due_date: {
type: ["string", "null"],
format: "date-time",
description: "Date ISO 8601 avec fuseau (ex: 2025-01-10T12:00:00Z). Peut être null."
},
actions: {
type: "array",
items: { type: "string", minLength: 1 },
minItems: 0,
description: "Étapes concrètes proposées. Ex: 'contacter le client', 'revérifier les logs'."
},
summary: {
type: "string",
minLength: 1,
description: "Résumé concis d'une ligne du ticket."
},
language: {
type: "string",
enum: ["fr", "en", "es", "de", "other"],
description: "Langue dominante de l'email."
}
},
required: ["schema_version", "intent", "priority", "summary", "actions"],
allOf: [
{
if: { properties: { intent: { const: "bug" } } },
then: { properties: { actions: { minItems: 1 } } }
},
{
if: { properties: { intent: { const: "feature_request" } } },
then: { properties: { actions: { minItems: 1 } } }
}
]
} as const;
// Zod mirror pour la validation côté serveur
const Priority = z.enum(["low", "medium", "high"]);
const Intent = z.enum(["question", "bug", "feature_request", "billing", "other"]);
const Language = z.enum(["fr", "en", "es", "de", "other"]);
export const TicketSchemaV1 = z.object({
schema_version: z.literal("v1"),
customer_id: z.string().regex(/^[A-Za-z0-9._-]+$/).nullable().describe("ID client ou null"),
intent: Intent,
priority: Priority,
due_date: z.string().datetime({ offset: true }).nullable(),
actions: z.array(z.string().min(1)),
summary: z.string().min(1),
language: Language
}).strict()
.transform((t) => {
const normalized = {
...t,
// Normalisation simple si le LLM varie la casse
intent: t.intent.toLowerCase() as z.infer<typeof Intent>,
priority: t.priority.toLowerCase() as z.infer<typeof Priority>,
language: t.language.toLowerCase() as z.infer<typeof Language>,
summary: t.summary.trim(),
actions: t.actions.map(a => a.trim()).filter(Boolean)
};
return normalized;
})
.superRefine((t, ctx) => {
const needsActions = t.intent === "bug" || t.intent === "feature_request" || t.intent === "billing";
if (needsActions && t.actions.length === 0) {
ctx.addIssue({
code: "custom",
path: ["actions"],
message: "Actions ne peut pas être vide pour intent ∈ {bug, feature_request, billing}."
});
}
if (t.priority === "high" && t.due_date === null) {
ctx.addIssue({
code: "custom",
path: ["due_date"],
message: "due_date doit être non nulle quand priority vaut high."
});
}
});
export type TicketV1 = z.infer<typeof TicketSchemaV1>;
Dans cet exemple, null et optional sont distingués. Lorsqu’un champ peut ne pas exister, on préfère souvent le rendre requis mais nullable pour lever l’ambiguïté: le champ existe toujours, ce qui simplifie l’aval. Les enums stricts pour priority et intent sont essentiels pour éviter les variantes inattendues. Les règles conditionnelles garantissent qu’un bug ou une feature_request s’accompagne d’actions concrètes, tandis que la priorité high exige une due_date non nulle dans notre politique.
Appeler ChatGPT en mode JSON Schema strict
Pour l’extraction, un modèle rapide et économique tel que gpt-4o-mini fonctionne très bien, surtout avec temperature faible pour le déterminisme. La clé est d’activer un response_format de type json_schema avec strict: true, d’écrire un message system qui interdit toute prose et exige du JSON seulement, et de désactiver le streaming pour simplifier l’analyse.
Voici un module src/llm.ts qui prépare les messages, appelle le LLM avec un schéma strict, et retourne la réponse brute du modèle:
// src/llm.ts
import "dotenv/config";
import OpenAI from "openai";
import { TicketSchemaV1, ticketJsonSchemaV1, type TicketV1 } from "./schema";
import { ZodError } from "zod";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export type ExtractOptions = {
timeoutMs?: number;
maxRetries?: number;
seed?: number;
temperature?: number;
};
const SYSTEM_PROMPT = `
Tu es un extracteur de données. Tu dois répondre UNIQUEMENT en JSON conforme au schéma fourni.
Aucune prose, aucun commentaire, aucun code bloc Markdown.
`;
function redactPII(input: string) {
// Exemple minimaliste: retire emails et numéros de téléphone
const noEmails = input.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[REDACTED_EMAIL]");
const noPhones = noEmails.replace(/\+?\d[\d\s().-]{7,}\d/g, "[REDACTED_PHONE]");
return noPhones;
}
function parseJsonWithLimit(text: string, maxBytes = 64 * 1024): unknown {
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
if (bytes.byteLength > maxBytes) {
throw new Error(`Réponse JSON trop volumineuse: ${bytes.byteLength} bytes > ${maxBytes}`);
}
return JSON.parse(text);
}
async function withTimeout<T>(p: Promise<T>, ms: number, desc = "operation"): Promise<T> {
const ctrl = new AbortController();
const timeout = setTimeout(() => ctrl.abort(), ms);
try {
// Si vous utilisez fetch manuellement, passez ctrl.signal.
// L'API OpenAI Responses gère un abort via signal si supporté par l’implémentation.
return await p;
} catch (e: any) {
if (ctrl.signal.aborted) throw new Error(`Timeout ${desc} après ${ms}ms`);
throw e;
} finally {
clearTimeout(timeout);
}
}
type LlmResult = {
text: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
model: string;
};
async function callLlmStrictJson(email: string, seed?: number, temperature = 0): Promise<LlmResult> {
const messages = [
{ role: "system" as const, content: SYSTEM_PROMPT },
{ role: "user" as const, content: redactPII(email) }
];
const response = await openai.responses.create({
model: "gpt-4o-mini",
temperature,
top_p: 1,
seed,
// Désactiver le streaming
stream: false,
messages,
response_format: {
type: "json_schema",
json_schema: {
name: "ticket_v1",
schema: ticketJsonSchemaV1,
strict: true
}
}
});
// La Responses API fournit output_text pour la vue texte concaténée
const text = response.output_text || (response as any).content || "";
return {
text,
usage: {
input_tokens: (response as any).usage?.input_tokens,
output_tokens: (response as any).usage?.output_tokens,
total_tokens: (response as any).usage?.total_tokens
},
model: response.model
};
}
export type ExtractOutcome =
| { ok: true; ticket: TicketV1; model: string; usage?: LlmResult["usage"]; repaired: number }
| { ok: false; error: string; model?: string; usage?: LlmResult["usage"]; repaired: number; details?: unknown };
export async function extractTicketFromEmail(
email: string,
opts: ExtractOptions = {}
): Promise<ExtractOutcome> {
const timeoutMs = opts.timeoutMs ?? 6000;
const maxRetries = opts.maxRetries ?? 2;
const seed = opts.seed ?? 42;
const temperature = opts.temperature ?? 0;
let lastError: unknown = null;
let repaired = 0;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await withTimeout(callLlmStrictJson(email, seed, temperature), timeoutMs, "LLM");
const json = parseJsonWithLimit(result.text);
const parsed = TicketSchemaV1.parse(json);
// Conversion Date après validation si nécessaire
// Exemple: on laisse due_date en string ISO dans le contrat, on convertira plus tard à l’aval.
// Simple contrôle de coût: calcul approximatif
const usage = result.usage;
return { ok: true, ticket: parsed, model: result.model, usage, repaired };
} catch (e: any) {
lastError = e;
if (e instanceof ZodError) {
// On tente une auto-réparation via un nouveau tour avec les erreurs
if (attempt < maxRetries) {
repaired += 1;
const fixed = await tryRepair(email, e);
if (fixed.ok) {
return { ok: true, ticket: fixed.ticket, model: fixed.model, usage: fixed.usage, repaired };
} else {
lastError = fixed.error;
}
}
} else {
// JSON.parse, timeout, ou erreur API
// On réessaie éventuellement si ce n'est pas le dernier tour
}
}
}
const message = lastError instanceof Error ? lastError.message : "Échec inconnu";
return { ok: false, error: message, repaired };
}
async function tryRepair(email: string, zodError: ZodError): Promise<ExtractOutcome> {
const messages = [
{ role: "system" as const, content: SYSTEM_PROMPT },
{
role: "developer" as const,
content:
"La réponse précédente ne respecte pas le schéma. Corrige la sortie. " +
"Ne renvoie que du JSON conforme au schéma envoyé via response_format. " +
"Erreurs Zod: " + JSON.stringify(zodError.issues, null, 2)
},
{ role: "user" as const, content: redactPII(email) }
];
const response = await openai.responses.create({
model: "gpt-4o-mini",
temperature: 0,
top_p: 1,
seed: 42,
stream: false,
messages,
response_format: {
type: "json_schema",
json_schema: {
name: "ticket_v1",
schema: ticketJsonSchemaV1,
strict: true
}
}
});
const text = response.output_text || (response as any).content || "";
try {
const json = parseJsonWithLimit(text);
const parsed = TicketSchemaV1.parse(json);
return { ok: true, ticket: parsed, model: response.model, usage: (response as any).usage, repaired: 1 };
} catch (e: any) {
const message = e instanceof Error ? e.message : "Échec réparation";
return { ok: false, error: message, model: response.model, usage: (response as any).usage, repaired: 1, details: e };
}
}
Dans cette implémentation, la variable SYSTEM_PROMPT est minimaliste et stricte. Le Schéma JSON est attaché au paramètre response_format avec strict: true, ce qui contraint le modèle à émettre un objet conforme. Le parsing JSON est défensif avec une limite de taille, et la validation Zod garantit l’intégrité applicative. La fonction tryRepair rejoue l’appel en fournissant les erreurs Zod pour guider la correction, tout en gardant le même schéma strict côté réponse.
Parser et valider côté serveur
Le parsing se fait toujours après l’appel, même avec response_format strict. En pratique, cela vous protège des régressions et des changements de modèle. Une fois le JSON parsé, validez-le avec Zod, normalisez ce qui peut l’être (trim des chaînes, lowercasing sur les enums), et différer la conversion des dates ISO en objets Date après la validation pour éviter des erreurs silencieuses. En cas d’invalidité, retournez un statut 422 avec des détails de validation non sensibles.
Voici un exemple d’utilisation de la fonction d’extraction dans src/index.ts, qui simule un email d’entrée et montre l’issue JSON validée:
// src/index.ts
import { extractTicketFromEmail } from "./llm";
async function main() {
const email = `
Bonjour,
Depuis hier soir, notre intégration Stripe échoue sur la création de paiement.
C'est urgent pour le lancement demain. Client: ACME-42.
Pouvez-vous regarder ? Merci.
`;
const outcome = await extractTicketFromEmail(email, { timeoutMs: 6000, maxRetries: 2, seed: 123, temperature: 0 });
if (outcome.ok) {
console.log("[OK] Ticket valide v1");
console.log(JSON.stringify(outcome.ticket, null, 2));
if (outcome.usage) {
console.log("Usage tokens:", outcome.usage);
}
} else {
console.error("[KO] Extraction invalide:", outcome.error);
if (outcome.details) console.error(outcome.details);
process.exitCode = 1;
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Si vous tenez à convertir due_date en Date, faites-le après avoir passé Zod, par exemple dans une couche de mapping vers votre domaine. Cette séparation évite de propager des Date invalides.
Auto-réparation: boucle de retry guidée par les erreurs
La boucle de réparation doit être bornée avec un budget clair, par exemple deux réessais au maximum pour éviter les spirales de coûts. Pour guider la correction, injectez les erreurs Zod dans un message developer, rappelez les règles “JSON uniquement” et laissez le schéma strict invariable côté response_format. En pratique, cela suffit à corriger la plupart des écarts mineurs, notamment des enums mal orthographiés ou des champs manquants.
La fonction tryRepair du module llm.ts illustre cette stratégie. Elle ajoute un message developer avec les issues Zod, rejoue l’appel avec le même schéma et vérifie immédiatement la validité. En cas d’échec persistant, retournez un résultat erroné de manière propre. Pensez à journaliser ces échecs avec l’email redacted pour analyse, sans PII.
Prompts minimaux mais stricts
Le prompt system doit rester compact et impératif: “Tu dois répondre UNIQUEMENT en JSON conforme au schéma X. Aucune prose.” Le rôle du user est de fournir uniquement l’email brut, sans indiquer la structure attendue ailleurs que via le schéma fourni à response_format. Les descriptions dans le schéma jouent le rôle de guidelines riches pour le modèle. Cette approche réduit la variabilité, améliore la robustesse, et supprime la tentation de multiplier les instructions textuelles redondantes qui peuvent entrer en conflit.
Par exemple, le message system utilisé plus haut suffit déjà, car la contrainte vient surtout du schéma strict. Le message user n’ajoute rien d’autre que le contenu libre à extraire. Ajouter des descriptions détaillées dans le schéma (priority, intent, due_date) guide le modèle sans poluer le prompt.
Tests rapides et jeux d’exemples
Pour stabiliser le pipeline, créez un dossier fixtures qui contient quelques emails représentatifs. Un premier email peut omettre l’ID client pour vérifier que customer_id devient null. Un second peut mentionner une date ambiguë afin de voir si la due_date reste null ou si le modèle propose une ISO 8601 valide. Un troisième peut exprimer l’urgence sans l’énoncer explicitement, pour tester la déduction de priority.
Un test automatisé peut vérifier la validation Zod et imposer des invariants métier. Voici un exemple minimal avec Node et assert, sans framework de test, en supposant que vous disposiez de plusieurs fixtures:
// test/smoke.test.ts
import assert from "node:assert/strict";
import { extractTicketFromEmail } from "../src/llm";
async function run() {
const emailHigh = "URGENT: l'API renvoie 500 depuis 2h pour client XYZ_99. Merci de corriger avant ce soir.";
const resHigh = await extractTicketFromEmail(emailHigh, { temperature: 0, seed: 1, maxRetries: 2 });
assert.ok(resHigh.ok, "Extraction doit réussir");
if (resHigh.ok) {
assert.equal(resHigh.ticket.schema_version, "v1");
assert.ok(["bug", "billing", "other", "question", "feature_request"].includes(resHigh.ticket.intent));
assert.ok(["low", "medium", "high"].includes(resHigh.ticket.priority));
if (resHigh.ticket.priority === "high") {
assert.notEqual(resHigh.ticket.due_date, null, "due_date non nulle quand priority=high");
}
}
const emailNoId = "Bonjour, je ne me souviens plus de mon identifiant client. Pouvez-vous m'aider ?";
const resNoId = await extractTicketFromEmail(emailNoId, { temperature: 0, seed: 2, maxRetries: 2 });
assert.ok(resNoId.ok);
if (resNoId.ok) {
assert.equal(resNoId.ticket.customer_id, null);
}
console.log("Tests smoke: OK");
}
run().catch((e) => {
console.error(e);
process.exit(1);
});
Pour un maximum de stabilité, fixez temperature à 0, utilisez seed quand disponible, et comparez des snapshots JSON triés si vous souhaitez détecter des variations. Vous pouvez aussi désactiver des champs volatils si votre logique le permet.
Intégrer dans une API HTTP
Exposez une API POST /tickets/extract qui accepte du text/plain (email brut) ou du JSON { email }. Ajoutez un rate limiting simple et une clé d’idempotence dérivée d’un hash de l’input pour éviter les réexécutions coûteuses. Un timeout global, par exemple de huit secondes, protège l’instance contre les blocages. La réponse doit retourner l’objet validé, y compris schema_version, ainsi que des métadonnées utiles comme repaired ou usage si vous le souhaitez.
Voici un exemple avec Express dans src/api.ts:
// src/api.ts
import express from "express";
import crypto from "node:crypto";
import { extractTicketFromEmail } from "./llm";
const app = express();
app.use(express.json({ limit: "32kb" }));
app.use(express.text({ type: "text/plain", limit: "32kb" }));
const requestsPerIp = new Map<string, { count: number; ts: number }>();
function rateLimit(ip: string, limit = 30, windowMs = 60_000) {
const now = Date.now();
const rec = requestsPerIp.get(ip) ?? { count: 0, ts: now };
if (now - rec.ts > windowMs) {
rec.count = 0;
rec.ts = now;
}
rec.count++;
requestsPerIp.set(ip, rec);
if (rec.count > limit) return false;
return true;
}
const idemCache = new Map<string, any>();
function idempotencyKey(input: string) {
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
}
app.post("/tickets/extract", async (req, res) => {
const ip = req.ip || "unknown";
if (!rateLimit(ip)) {
res.status(429).json({ error: "Rate limit exceeded" });
return;
}
const email = typeof req.body === "string" ? req.body : req.body?.email;
if (typeof email !== "string" || email.trim().length < 3) {
res.status(400).json({ error: "email manquant" });
return;
}
const key = idempotencyKey(email);
if (idemCache.has(key)) {
res.status(200).json({ ...idemCache.get(key), idempotent: true });
return;
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8000);
try {
const outcome = await extractTicketFromEmail(email, { timeoutMs: 6000, maxRetries: 2, seed: 123, temperature: 0 });
if (outcome.ok) {
const payload = { ticket: outcome.ticket, repaired: outcome.repaired, schema_version: outcome.ticket.schema_version };
idemCache.set(key, payload);
res.status(200).json(payload);
} else {
res.status(422).json({ error: "invalid_ticket", message: outcome.error });
}
} catch (e: any) {
res.status(500).json({ error: "server_error", message: e?.message || "unknown" });
} finally {
clearTimeout(t);
}
});
export function start(port = 3000) {
app.listen(port, () => {
console.log(`API listening on http://localhost:${port}`);
});
}
Démarrez ensuite votre serveur dans un point d’entrée dédié:
// src/server.ts
import { start } from "./api";
start(3000);
Vous pouvez maintenant envoyer un email brut via curl:
curl -X POST http://localhost:3000/tickets/extract \
-H "Content-Type: text/plain" \
--data-binary {{content}}#039;Bonjour, facture non reçue pour client ACME-42. Merci.'
La réponse contiendra le JSON validé qui respecte votre schéma v1, ainsi que schema_version pour permettre une évolution contrôlée.
Observabilité, coûts et garde-fous
En production, journalisez les tokens consommés (prompt et complétion), la latence, le nombre de réparations tentées et le taux d’échec final. Cela permet d’identifier des régressions dès qu’un modèle change légèrement. Vous pouvez imposer un plafond de coût par requête en estimant les coûts à partir des tokens. Par exemple, avec gpt-4o-mini, calculez un coût approximatif en multipliant les tokens par un prix unitaire en millions et refusez la requête si vous dépassez un seuil.
Ajoutez un fallback rules-based si le budget est dépassé ou si les essais échouent. Par exemple, extrayez un customer_id via regex, détectez des mots-clés simples pour intent et priority et retournez un JSON minimal avec schema_version. Ce n’est pas idéal, mais cela évite de renvoyer une erreur franche si vous préférez une dégradation gracieuse.
Mettez en place un anonymiseur avant l’envoi au LLM si vous traitez des emails contenant des PII. Le petit utilitaire redactPII du module llm.ts démontre comment masquer rapidement les emails et numéros de téléphone. Dans des environnements très sensibles, envisagez une stratégie de redaction plus avancée ou du processing on-prem si votre politique de sécurité l’exige.
Enfin, versionnez votre schéma et mesurez l’impact de tout changement avant déploiement global. Conservez le champ schema_version dans la réponse et préparez une période de compatibilité où votre backend peut accepter plusieurs versions (v1, v2) simultanément, avec des convertisseurs si nécessaire.
Checklist de prod
Avant de considérer votre pipeline prêt pour la production, assurez-vous que la validation Zod est systématique et que vous n’utilisez jamais la sortie brute du modèle sans passer par le parseur et le validateur. Vérifiez que les retries sont bornés, idempotents via une clé basée sur un hash de l’entrée, et que les logs incluent un correlation-id pour agréger les événements d’une même requête.
Testez des cas adverses comme des emails contenant du HTML, des pièces jointes simulées via du texte encodé, et des langues mixtes. Ces scénarios révèlent souvent des coins sombres de votre logique de parsing ou de vos prompts. Configurez un monitoring d’alertes sur un seuil de taux d’invalidité ou sur des pics de latence anormaux afin de réagir rapidement à une régression de modèle.
Documentez votre contrat API pour les consommateurs internes, y compris les règles métier (par exemple priority=high implique due_date non nulle), la sémantique de null vs champs optionnels, et les valeurs autorisées pour les enums. Indiquez clairement la version du schéma et le calendrier d’évolution pour éviter les surprises lors d’un changement de version.
Checklist
Relisez l’article et le code afin de supprimer les incohérences et vérifier que les noms des fichiers et des exports correspondent exactement à ceux des snippets. Testez les commandes et les extraits fournis en lançant les scripts Node, en appelant l’API HTTP et en validant que la sortie est bien conforme à v1, y compris les règles conditionnelles. Publiez lorsque vos tests smoke et vos tests d’intégration passent, puis surveillez les métriques en environnement réel pour confirmer que le taux d’échec reste inférieur à 1% et que les coûts par requête sont dans la fourchette attendue.
Conclusion
Forcer un JSON fiable avec un schéma strict côté LLM et valider avec Zod côté serveur transforme un “meilleur effort” en un contrat robuste et vérifiable. Les prompts minimalistes, le response_format en JSON Schema strict et la boucle d’auto-réparation permettent d’atteindre des taux d’échec très faibles sans complexifier la logique. Avec une API HTTP propre, un suivi coûts/latence et un versionnement de schéma, vous obtenez une brique d’extraction prête pour la production, adaptable à d’autres cas d’usage que les tickets support.
Ressources
Pour aller plus loin, consultez la documentation OpenAI sur la Responses API et le paramètre response_format en JSON Schema, la documentation de Zod pour la validation runtime, la spécification JSON Schema Draft 2020-12 et la norme ISO 8601 pour les dates. Vous pouvez également explorer AbortController et les patterns d’idempotence pour les APIs HTTP Node. Liens utiles: https://platform.openai.com/docs, https://zod.dev, https://json-schema.org, https://en.wikipedia.org/wiki/ISO_8601, https://developer.mozilla.org/docs/Web/API/AbortController.