route backend complète avec :

  • appel OpenAI,
  • validation Zod (patch strict),
  • normalisation (cm/kg, ISO-8601),
  • et un exemple cURL pour tester.


Parfait — voici la route backend complète avec :

  • appel OpenAI,
  • validation Zod (patch strict),
  • normalisation (cm/kg, ISO-8601),
  • et un exemple cURL pour tester.

Vous avez deux options : Next.js (App Router) ou Express. Choisissez l’une (le cœur est identique).

Option A — Next.js (App Router) app/api/extract/route.ts

// app/api/extract/route.ts import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { OpenAI } from "openai"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); /** --- 1) Schéma Zod du patch JSON que DOIT renvoyer le LLM --- */ const Sender = z.object({ name: z.string().optional(), phone: z.string().optional(), email: z.string().email().optional() }).partial(); const Package = z.object({ width_cm: z.number().positive().optional(), length_cm: z.number().positive().optional(), height_cm: z.number().positive().optional(), weight_kg: z.number().positive().optional() }).partial(); const Pickup = z.object({ address: z.string().optional(), notes: z.string().optional(), earliest_datetime: z.string().datetime().optional(), latest_datetime: z.string().datetime().optional() }).partial(); const Dropoff = z.object({ address: z.string().optional(), deadline_datetime: z.string().datetime().optional() }).partial(); const PatchSchema = z.object({ sale_order: z.string().optional(), sender: Sender.optional(), delivery_freelance: z.string().optional(), delivery_site: z.string().optional(), package_type: z.string().optional(), user_link: z.string().optional(), track_link: z.string().optional(), package: Package.optional(), pickup: Pickup.optional(), dropoff: Dropoff.optional(), urgency: z.enum(["faible","normal","élevé"]).optional(), extra: z.record(z.any()).optional() }).strict(); /** --- 2) Aides: merge + normalisations simples --- */ function deepMerge<T extends Record<string, any>>(base: T, patch: Partial<T>): T { const out: any = Array.isArray(base) ? [...(base as any)] : { ...base }; for (const k of Object.keys(patch || {})) { const v = (patch as any)[k]; if (v && typeof v === "object" && !Array.isArray(v)) out[k] = deepMerge(out[k] || {}, v); else out[k] = v; } return out; } /** Convertit "40x30x20" → {width_cm:40,length_cm:30,height_cm:20} + "6 kg" → weight_kg */ function naiveExtract(text: string) { const patch: any = {}; const dim = text.match(/(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)/i); if (dim) { patch.package ??= {}; patch.package.width_cm = parseFloat(dim[1].replace(",", ".")); patch.package.length_cm = parseFloat(dim[2].replace(",", ".")); patch.package.height_cm = parseFloat(dim[3].replace(",", ".")); } const kg = text.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i); if (kg) { patch.package ??= {}; patch.package.weight_kg = parseFloat(kg[1].replace(",", ".")); } // urgence if (/\burgen(c|ce)\b.*(élev|haut|fort)/i.test(text) || /\bpriorit(é|e)\s*1\b/i.test(text)) patch.urgency = "élevé"; else if (/\burgen(c|ce)\b/i.test(text)) patch.urgency = "normal"; return patch; } /** --- 3) Prompts pour extraction stricte JSON --- */ const SYSTEM_PROMPT = ` Vous êtes un extracteur de variables pour un bot logistique. À partir d'un message utilisateur, inférez UNIQUEMENT les champs détectés et répondez en JSON strict conforme au schéma: { sale_order?, sender?:{name?,phone?,email?}, delivery_freelance?, delivery_site?, package_type?, user_link?, track_link?, package?:{width_cm?,length_cm?,height_cm?,weight_kg?}, pickup?:{address?,notes?,earliest_datetime?,latest_datetime?}, dropoff?:{address?,deadline_datetime?}, urgency?:("faible"|"normal"|"élevé"), extra? } Règles: - Dimensions en centimètres (width×length×height). Si "40x30x20", interpréter en cm. - Poids en kilogrammes (nombre). - Datetimes ISO 8601 "YYYY-MM-DDTHH:mm" (si date sans heure → 08:00 par défaut; si plage "9-12h" → earliest 09:00 / latest 12:00 le même jour si plausible). - Pas de texte explicatif. Réponse = JSON uniquement. `; function userPrompt(userText: string, current: unknown) { return ` Texte utilisateur: """ ${userText} """ Contexte courant (peut être incomplet), pour éviter de répéter les champs déjà remplis: ${JSON.stringify(current ?? {}, null, 2)} Retournez uniquement les champs détectés (un patch JSON, pas l'objet complet). `; } /** --- 4) Handler --- */ export async function POST(req: NextRequest) { try { const { userText, current } = await req.json(); if (typeof userText !== "string") { return NextResponse.json({ error: "userText manquant" }, { status: 400 }); } // a) Fallback local (regex) pour résilience let patch: Record<string, any> = naiveExtract(userText); // b) Appel OpenAI (réponse STRICTEMENT JSON) const chat = await openai.chat.completions.create({ model: "gpt-4o-mini", // économique et suffisant pour extraction; adaptez si besoin temperature: 0, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userPrompt(userText, current) } ], response_format: { type: "json_object" } }); const raw = chat.choices[0]?.message?.content?.trim() || "{}"; let llmPatch: any = {}; try { llmPatch = JSON.parse(raw); } catch {} // c) Fusion + validation Zod const mergedPatch = deepMerge(patch, llmPatch); const parsed = PatchSchema.parse(mergedPatch); // d) Normalisations finales (ex: heures par défaut si ISO date simple) const norm = (s?: string) => s && /^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s}T08:00` : s; if (parsed.pickup?.earliest_datetime) parsed.pickup.earliest_datetime = norm(parsed.pickup.earliest_datetime)!; if (parsed.pickup?.latest_datetime) parsed.pickup.latest_datetime = norm(parsed.pickup.latest_datetime)!; if (parsed.dropoff?.deadline_datetime) parsed.dropoff.deadline_datetime = norm(parsed.dropoff.deadline_datetime)!; return NextResponse.json(parsed, { status: 200 }); } catch (err: any) { console.error("extract error:", err); return NextResponse.json({ error: err?.message ?? "Erreur serveur" }, { status: 500 }); } }

Env :

# .env.local OPENAI_API_KEY=sk-...

Option B — Express routes/extract.ts

import express from "express"; import { z } from "zod"; import { OpenAI } from "openai"; const router = express.Router(); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // (réutilisez EXACTEMENT les schémas, helpers et prompts de l'option A) // … importez/collez Sender/Package/Pickup/Dropoff/PatchSchema, deepMerge, naiveExtract, SYSTEM_PROMPT, userPrompt … router.post("/extract", async (req, res) => { try { const { userText, current } = req.body; if (typeof userText !== "string") return res.status(400).json({ error: "userText manquant" }); let patch: any = naiveExtract(userText); const chat = await openai.chat.completions.create({ model: "gpt-4o-mini", temperature: 0, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userPrompt(userText, current) } ], response_format: { type: "json_object" } }); const raw = chat.choices[0]?.message?.content?.trim() || "{}"; let llmPatch: any = {}; try { llmPatch = JSON.parse(raw); } catch {} const mergedPatch = deepMerge(patch, llmPatch); const parsed = PatchSchema.parse(mergedPatch); const norm = (s?: string) => s && /^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s}T08:00` : s; if (parsed.pickup?.earliest_datetime) parsed.pickup.earliest_datetime = norm(parsed.pickup.earliest_datetime)!; if (parsed.pickup?.latest_datetime) parsed.pickup.latest_datetime = norm(parsed.pickup.latest_datetime)!; if (parsed.dropoff?.deadline_datetime) parsed.dropoff.deadline_datetime = norm(parsed.dropoff.deadline_datetime)!; res.json(parsed); } catch (e: any) { console.error(e); res.status(500).json({ error: e?.message ?? "Erreur serveur" }); } }); export default router;

Test rapide (cURL)

curl -X POST http://localhost:3000/api/extract \ -H "Content-Type: application/json" \ -d '{ "userText": "Ramassage 12 rue de la Paix, Paris demain 9-12h. Livraison 60 av du Prado, Marseille avant vendredi 17h. Carton 40x30x20 cm, 6 kg. Urgence élevée. Contact: Marie Dupont +33600000000 marie@ex.com", "current": {} }'

Réponse attendue (ex.)

{ "pickup": { "address": "12 rue de la Paix, Paris", "earliest_datetime": "2025-09-23T09:00", "latest_datetime": "2025-09-23T12:00" }, "dropoff": { "address": "60 av du Prado, Marseille", "deadline_datetime": "2025-09-26T17:00" }, "package_type": "carton", "package": { "width_cm": 40, "length_cm": 30, "height_cm": 20, "weight_kg": 6 }, "urgency": "élevé", "sender": { "name": "Marie Dupont", "phone": "+33600000000", "email": "marie@ex.com" } }

Intégration côté bot React (votre composant)

Dans votre composant existant, remplacez le placeholder :

async function extractWithOpenAI(userText: string, current: Shipment): Promise<Partial<Shipment>> { const res = await fetch("/api/extract", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userText, current }) }); if (!res.ok) return {}; return await res.json(); }

Sécurité & qualité

  • Clé OpenAI uniquement côté serveur.
  • Rate limiting (ex. IP-based) recommandé sur /api/extract.
  • Validation stricte (Zod) déjà incluse → rejette tout JSON hors schéma.
  • Journalisation : loggez les patches et les décisions (utile pour QA).
  • i18n : si vous ciblez FR/MG, ajoutez un champ locale dans le prompt et mappez élevé ⇄ ambony, etc.