Pack pour votre bot ReactJS (FR) avec un "pilote un dialogue guidé" orientée logistique
- Contexte : une BD ODOO avec des champs "sale order", "sender", "delivery freelance", "delivery site", "package type", "user link", "Track link", detail package: width, length, depth >
- > ces données sont stocké dans un fichier json,
REMARQUES LIMINAIRE : ce n'est pas un RAG mais une 📌 Extraction structurée (slot-filling)
👉 La méthode que nous allons détailler (bot logistique qui extrait des champs structurés à partir d’un texte utilisateur, via un prompt + LLM) n’est pas du RAG au sens classique.
📌 RAG (Retrieval-Augmented Generation)
-
Principe :
- Vous avez une base documentaire externe (ex. manuels, FAQ, fiches produits).
- Vous indexez ce contenu en vecteurs (embeddings).
- Lors d’une question, vous retrouvez les passages pertinents et vous les injectez dans le prompt.
- Le LLM génère une réponse augmentée par ces données.
- Cas typiques : FAQ clients, chatbot SAV, copilote sur base technique.
- Avantage : la réponse est toujours enrichie par vos propres données, pas seulement par les connaissances internes du LLM.
📌 Extraction structurée (slot-filling)
Principe :
- L’utilisateur donne un texte libre.
- Le LLM parse et retourne un patch JSON avec les variables utiles (ex. pickup.address, package.weight_kg).
- Vous fusionnez ces patchs pour compléter le JSON global (shipment).
Cas typiques : assistants logistiques, formulaires intelligents, CRM autofill, booking bots.
Avantage : la sortie est structurée et directement exploitable par un SI (ERP/CRM).
📌 Différence clé
- RAG = on nourrit le LLM avec de l’information externe (knowledge base).
- Extraction slot-filling = on demande au LLM de structurer l’énoncé utilisateur selon un schéma.
Les deux peuvent se combiner :
-
Exemple : le client dit « Envoyez un colis de Tana à Diego avec Chronopost, comme la dernière fois ».
- RAG pourrait retrouver dans vos historiques ou docs que “Chronopost Madagascar → service X”.
- Extraction remplirait ensuite le JSON avec delivery_site = Chronopost Tana.
✅ Donc : ce n’est pas du RAG, mais plutôt du LLM structured extraction / slot-filling.
⚡ Vous pouvez toutefois l’enrichir par du RAG si vous voulez que le bot comprenne des contextes externes (tarifs transporteurs, conditions de livraison, adresses stockées, etc.).
Donc avant pour le Pack prêt-à-intégrer pour votre bot ReactJS (FR)
La question à GPT 5 = Peux pack prêt-à-intégrer pour votre bot ReactJS (FR) qui collecte les infos d’expédition à partir de vos champs JSON existants et pilote un dialogue guidé.
" Nous ferions un dialogue de logistique sous un bot écrit en ReactJS. Et avec ces datas-là, on aimerait faire un dialogue avec d'un côté le client qui souhaite faire envoyer une parcelle de tel endroit à tel endroit géographiquement donnant l'adresse de départ et d'arrivée, donc les informations de taille, de poids, donc la taille, l'âge, largeur, longueur, hauteur, et aussi de savoir l'urgence probablement de l'envoi et les dates estimées de l'envoi pour la récupération du colis. Est-ce que tu peux me faire un dialogue de type bot qui pourrait être inséré dans la boîte du dialogue du bot ReactJS et cela avec un moteur de langage qui est OpenAI? S'il te plaît, merci. )
réponse > Voici un pack prêt-à-intégrer pour votre bot ReactJS (FR) qui collecte les infos d’expédition à partir de vos champs JSON existants et pilote un dialogue guidé. Le moteur LLM (OpenAI) est prévu via une fonction d’extraction « slot-filling » que vous pourrez brancher à votre API.
1) Schéma JSON (compatible avec vos champs)
{
"sale_order": "",
"sender": {
"name": "",
"phone": "",
"email": ""
},
"delivery_freelance": "",
"delivery_site": "",
"package_type": "",
"user_link": "",
"track_link": "",
"package": {
"width_cm": null,
"length_cm": null,
"height_cm": null,
"weight_kg": null
},
"pickup": {
"address": "",
"notes": "",
"earliest_datetime": "",
"latest_datetime": ""
},
"dropoff": {
"address": "",
"deadline_datetime": ""
},
"urgency": "normal",
"extra": {}
}
Explication 1) Schéma JSON (structure des données)
👉 C’est la cible finale : toutes les informations de l’envoi doivent être rassemblées dans ce format unique.
-
Pourquoi JSON ?
Parce que c’est le format standard pour échanger des données entre votre bot React, votre backend (Node/Express ou Next.js), et Odoo.
Chaque “champ” représente un slot que le bot doit remplir au fur et à mesure de la conversation. - Exemple :
{ "sale_order": "SO2025-001", "sender": { "name": "Marie Dupont", "phone": "+33600000000", "email": "marie@ex.com" }, "delivery_freelance": "Jean Randri", "delivery_site": "Agence Tana Nord", "package_type": "carton", "user_link": "https://app.monsite.com/users/123", "track_link": "https://track.monsite.com/colis/456", "package": { "width_cm": 40, "length_cm": 30, "height_cm": 20, "weight_kg": 6 }, "pickup": { "address": "12 rue de la Paix, Paris", "notes": "digicode 1234", "earliest_datetime": "2025-09-23T09:00", "latest_datetime": "2025-09-23T12:00" }, "dropoff": { "address": "60 avenue du Prado, Marseille", "deadline_datetime": "2025-09-26T17:00" }, "urgency": "élevé", "extra": {} }
👉 C’est ce JSON qui sera envoyé à Odoo pour créer un devis ou un ordre de livraison.
2) Script de dialogue (FR)
Ordre recommandé (le bot pose la question suivante seulement si le slot n’est pas rempli) :
- Adresse de départ – « D’où récupérons-nous le colis ? (adresse complète) »
- Adresse d’arrivée – « Où livrons-nous ? (adresse complète) »
- Type de colis – « Quel type de colis ? (ex : carton, enveloppe, palette) »
- Dimensions – « Quelles dimensions (cm) ? largeur × longueur × hauteur »
- Poids – « Quel poids (kg) ? »
- Fenêtre de récupération – « Quand pouvons-nous récupérer le colis ? (date/heure la plus tôt et la plus tard) »
- Échéance de livraison – « Date/heure limite souhaitée pour la livraison ? »
- Urgence – « Urgence ? (faible / normal / élevé) »
- Coordonnées expéditeur – « Nom + mobile + e-mail du contact expéditeur »
- Notes (optionnel) – « Instructions particulières (digicode, étage, fragile, etc.) »
- Récapitulatif & validation – le bot relit tout, puis « Confirmez-vous cette demande d’expédition ? (oui/non) ».
Explication
Script de dialogue (FR)
👉 C’est la stratégie de collecte des données : le bot guide le client pas à pas.
- Le bot pose des questions dans un ordre logique.
- Il s’arrête dès qu’il a toutes les infos.
- Si une info est donnée spontanément par le client (ex : “Je veux envoyer un carton 40×30×20 cm de Paris à Marseille demain matin”), alors on saute les questions correspondantes.
Ordre recommandé :
- Adresse de départ → pickup.address
- Adresse d’arrivée → dropoff.address
- Type de colis → package_type
- Dimensions → package.width_cm, length_cm, height_cm
- Poids → package.weight_kg
- Créneau de ramassage → pickup.earliest_datetime, latest_datetime
- Échéance de livraison → dropoff.deadline_datetime
- Urgence → urgency
- Coordonnées expéditeur → sender.name, sender.phone, sender.email
- Notes optionnelles → pickup.notes
- Récapitulatif → confirmation avant envoi
👉 Ce process s’appelle du slot-filling (remplissage de cases vides).
Autre explication ( par Gabriel ou ramenos ou qui de droit. )
Notre équipe d'assistance met tout en œuvre pour vous répondre aussi rapidement que les assistants vocaux.
3) Prompt d’extraction (OpenAI) pour le slot-filling
Utilisez le LLM pour interpréter la dernière phrase de l’utilisateur et remplir/mettre à jour les champs manquants.
Le modèle doit répondre uniquement en JSON selon le mini-schéma ci-dessous (seulement les champs détectés).
System prompt (FR) :
Exemple de prompt système (System prompt) :
Vous êtes un extracteur de variables pour un bot logistique. À partir de l’énoncé utilisateur, extrayez UNIQUEMENT les champs détectés et retournez un JSON strict. Unités attendues : dimensions en cm, poids en kg, datetimes au format ISO 8601 (YYYY-MM-DDTHH:mm). Si des unités sont implicites (ex. "20x30x15"), interprétez comme largeur x longueur x hauteur en cm. Ne retournez PAS de texte explicatif, seulement du JSON.
User template (ex) : (perl ??? )
Exemple d’appel utilisateur (User prompt) :
Texte utilisateur: "Ramassage 12 rue de la Paix, Paris demain 9h-12h. Livraison 60 avenue du Prado, Marseille avant vendredi 17h. Colis carton 40x30x20 cm, 6 kg. Urgence élevée. Contact: Marie Dupont +33600000000 marie@ex.com" Schéma partiel attendu: { "pickup": {...}, "dropoff": {...}, "package": {...}, "package_type": "", "urgency": "", "sender": {...} }
JSON attendu (ex) :
Réponse attendue du LLM :
{ "pickup": { "address": "12 rue de la Paix, Paris", "earliest_datetime": "2025-09-23T09:00", "latest_datetime": "2025-09-23T12:00" }, "dropoff": { "address": "60 avenue 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" } }
👉 Ensuite, ce patch est fusionné dans le JSON global (celui de l’étape 1).
> explication Prompt d’extraction (OpenAI)
👉 Ici on utilise un modèle OpenAI (ex. gpt-4o-mini) pour transformer le texte libre du client en données structurées JSON.
Rôle du LLM :
- Lire ce que dit l’utilisateur (ex : “Colis carton 40×30×20 cm, 6 kg, départ demain 9h à Paris, arrivée Marseille avant vendredi 17h”).
- Extraire les champs correspondants (pickup, dropoff, package, urgency…).
- Retourner uniquement un patch JSON (les champs nouveaux trouvés), sans phrase explicative.
.
✅ Donc en résumé :
- Étape 1 : définir le conteneur (schéma JSON complet).
- Étape 2 : créer un dialogue guidé (questions dans l’ordre si besoin).
- Étape 3 : brancher un LLM pour comprendre les phrases libres de l’utilisateur et remplir les cases du schéma.
4) Composant React (TypeScript) – Chat minimal + FSM + hook OpenAI
Design simple (Tailwind), slot-filling robuste.
La fonction extractWithOpenAI est isolée : branchez-la sur votre backend/proxy qui appelle l’API OpenAI.
import React, { useMemo, useRef, useState } from "react"; type Shipment = { sale_order?: string; sender?: { name?: string; phone?: string; email?: string }; delivery_freelance?: string; delivery_site?: string; package_type?: string; user_link?: string; track_link?: string; package?: { width_cm?: number|null; length_cm?: number|null; height_cm?: number|null; weight_kg?: number|null }; pickup?: { address?: string; notes?: string; earliest_datetime?: string; latest_datetime?: string }; dropoff?: { address?: string; deadline_datetime?: string }; urgency?: "faible"|"normal"|"élevé"; extra?: Record<string, any>; }; type Msg = { role: "bot" | "user"; text: string }; const INITIAL_PROMPTS = [ "Bonjour 👋 Je vous aide à créer votre demande d’expédition.", "D’où récupérons-nous le colis ? (adresse complète)" ]; const QUESTIONS = [ { key: "pickup.address", q: "D’où récupérons-nous le colis ? (adresse complète)" }, { key: "dropoff.address", q: "Où livrons-nous ? (adresse complète)" }, { key: "package_type", q: "Quel type de colis ? (ex : carton, enveloppe, palette)" }, { key: "package.dimensions", q: "Quelles dimensions (cm) ? largeur × longueur × hauteur (ex : 40×30×20)" }, { key: "package.weight_kg", q: "Quel poids (kg) ?" }, { key: "pickup.window", q: "Quand pouvons-nous récupérer le colis ? Donnez un créneau (ex : 2025-09-23 09:00–12:00)" }, { key: "dropoff.deadline", q: "Date/heure limite souhaitée pour la livraison ? (ex : 2025-09-26 17:00)" }, { key: "urgency", q: "Niveau d’urgence ? (faible / normal / élevé)" }, { key: "sender", q: "Nom + mobile + e-mail du contact expéditeur ?" }, { key: "pickup.notes", q: "Des instructions particulières ? (optionnel) Sinon dites « non »." } ]; function hasAllRequired(s: Shipment): boolean { const p = s.package || {}; return Boolean( s.pickup?.address && s.dropoff?.address && s.package_type && p.width_cm && p.length_cm && p.height_cm && p.weight_kg && s.pickup?.earliest_datetime && s.pickup?.latest_datetime && s.dropoff?.deadline_datetime && s.urgency && s.sender?.name && s.sender?.phone && s.sender?.email ); } /** Utilitaire: fusionne un patch JSON (issu de l’LLM) dans l’état courant */ 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; } /** Extraction locale simple (regex) pour dimensions et poids, en secours si LLM indisponible */ function naiveExtract(text: string): Partial<Shipment> { const patch: Partial<Shipment> = {}; const dim = text.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i); if (dim) { patch.package = { ...(patch.package || {}), width_cm: +dim[1], length_cm: +dim[2], height_cm: +dim[3] }; } const kg = text.match(/(\d+(?:[.,]\d+)?)\s*kg/i); if (kg) { patch.package = { ...(patch.package || {}), weight_kg: parseFloat(kg[1].replace(",", ".")) }; } return patch; } /** >>> À BRANCHER : appelez votre backend qui appelle OpenAI pour retourner un JSON de patch. */ async function extractWithOpenAI(userText: string, current: Shipment): Promise<Partial<Shipment>> { // Exemple: POST vers votre route /api/extract qui encapsule votre clé OpenAI côté serveur. // 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(); return {}; // placeholder si non branché } function prettySummary(s: Shipment): string { const p = s.package!; return [ "Voici votre récapitulatif :", `• Ramassage : ${s.pickup?.address} (créneau ${s.pickup?.earliest_datetime} – ${s.pickup?.latest_datetime})`, `• Livraison : ${s.dropoff?.address} (avant ${s.dropoff?.deadline_datetime})`, `• Colis : ${s.package_type}, ${p.width_cm}×${p.length_cm}×${p.height_cm} cm, ${p.weight_kg} kg`, `• Urgence : ${s.urgency}`, `• Contact expéditeur : ${s.sender?.name}, ${s.sender?.phone}, ${s.sender?.email}`, s.pickup?.notes ? `• Notes : ${s.pickup?.notes}` : `• Notes : —`, "", "Confirmez-vous cette demande d’expédition ? (oui/non)" ].join("\n"); } export default function LogisticsBot() { const [messages, setMessages] = useState<Msg[]>(INITIAL_PROMPTS.map(t => ({ role: "bot", text: t }))); const [input, setInput] = useState(""); const [data, setData] = useState<Shipment>({ package: {}, pickup: {}, dropoff: {}, sender: {}, extra: {} }); const [confirmed, setConfirmed] = useState<null|boolean>(null); const pendingRef = useRef(false); async function handleUserSubmit() { if (!input.trim() || pendingRef.current) return; const userText = input.trim(); setMessages(m => [...m, { role: "user", text: userText }]); setInput(""); pendingRef.current = true; // 1) Extraire via OpenAI (si branché) + fallback regex let patch: Partial<Shipment> = {}; try { patch = await extractWithOpenAI(userText, data); } catch {} patch = deepMerge(naiveExtract(userText), patch); if (userText.toLowerCase() === "non" && (data.pickup?.notes ?? "") === "") { patch = deepMerge(patch, { pickup: { notes: "" } }); } const next = deepMerge(data, patch); setData(next); // 2) Si tout est rempli et pas encore confirmé => récapitulatif if (hasAllRequired(next) && confirmed === null) { setMessages(m => [...m, { role: "bot", text: prettySummary(next) }]); pendingRef.current = false; return; } // 3) Sinon, poser la prochaine question manquante const ask = nextQuestion(next); setMessages(m => [...m, { role: "bot", text: ask }]); pendingRef.current = false; } function nextQuestion(s: Shipment): string { const p = s.package || {}; const need = (k: string) => { switch (k) { case "pickup.address": return !s.pickup?.address; case "dropoff.address": return !s.dropoff?.address; case "package_type": return !s.package_type; case "package.dimensions": return !(p.width_cm && p.length_cm && p.height_cm); case "package.weight_kg": return !p.weight_kg; case "pickup.window": return !(s.pickup?.earliest_datetime && s.pickup?.latest_datetime); case "dropoff.deadline": return !s.dropoff?.deadline_datetime; case "urgency": return !s.urgency; case "sender": return !(s.sender?.name && s.sender?.phone && s.sender?.email); case "pickup.notes": return s.pickup?.notes === undefined; default: return false; } }; const q = QUESTIONS.find(q => need(q.key))?.q; return q || "Je traite vos informations…"; } // Gestion de la confirmation finale React.useEffect(() => { const last = messages[messages.length - 1]?.text?.toLowerCase?.(); if (hasAllRequired(data) && confirmed === null && last) { if (["oui", "valide", "ok", "confirmé", "confirme"].some(w => last.includes(w))) { setConfirmed(true); // 👉 Ici, envoyez `data` vers votre backend/Odoo setMessages(m => [...m, { role: "bot", text: "Parfait ✅ Votre demande est enregistrée. Un lien de suivi vous sera communiqué." }]); } else if (["non", "corriger", "modifier"].some(w => last.includes(w))) { setConfirmed(false); setMessages(m => [...m, { role: "bot", text: "D’accord. Dites ce que vous souhaitez modifier." }]); } } }, [messages, data, confirmed]); const jsonPretty = useMemo(() => JSON.stringify(data, null, 2), [data]); return ( <div className="w-full max-w-2xl mx-auto p-4"> <div className="border rounded-2xl p-4 h-[540px] overflow-y-auto space-y-3 shadow-sm bg-white"> {messages.map((m, i) => ( <div key={i} className={`whitespace-pre-wrap ${m.role === "bot" ? "text-gray-900" : "text-blue-900 text-right"}`}> <span className={`inline-block rounded-2xl px-3 py-2 ${m.role === "bot" ? "bg-gray-100" : "bg-blue-50"}`}>{m.text}</span> </div> ))} </div> <div className="mt-3 flex gap-2"> <input className="flex-1 border rounded-xl px-3 py-2 outline-none" placeholder="Écrivez votre réponse…" value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleUserSubmit()} /> <button onClick={handleUserSubmit} className="px-4 py-2 rounded-xl bg-black text-white">Envoyer</button> </div> <details className="mt-4"> <summary className="cursor-pointer text-sm text-gray-600">Voir le JSON en temps réel</summary> <pre className="mt-2 text-xs bg-gray-50 p-3 rounded-lg">{jsonPretty}</pre> </details> </div> ); }
Voici l’explication “ligne de force par ligne de code” de l’étape 4 — ce composant React sert de mini-assistant conversationnel avec une FSM (machine à états simple) et un hook d’extraction branchable sur OpenAI.
Vue d’ensemble
- Objectif : guider l’utilisateur, remplir progressivement les « slots » (champs) du Shipment, récapituler, puis confirmer.
-
FSM implicite (sans librairie externe) :
- ASKING → le bot choisit la prochaine question manquante
- EXTRACTING → il extrait les infos de la réponse (OpenAI + fallback regex)
- SUMMARY → quand tous les champs requis sont remplis, il lit le récap
- CONFIRM → attend “oui/non”, puis finalise (envoi vers backend/Odoo)
pendingRef empêche les doubles envois pendant l’extraction.
Les blocs importants
1) Types & données
- type Shipment : contrat de données (même shape que votre JSON final).
- type Msg : structure des messages (bot ou user) pour l’affichage.
- INITIAL_PROMPTS : salut + première question.
- QUESTIONS : plan de dialogue, chaque entrée a un key logique et une question q.
2) Fonctions utilitaires
- hasAllRequired(s) : vérifie que tous les champs indispensables sont présents → déclenche le récap.
- deepMerge(base, patch) : fusionne proprement un patch JSON (ce que renvoie l’LLM) dans l’état courant, en préservant les sous-objets déjà remplis.
- naiveExtract(text) : filet de sécurité côté front (regex) pour capter dimensions “40×30×20” et poids “6 kg” si l’API LLM ne répond pas.
3) Hook “LLM”
-
extractWithOpenAI(userText, current) : placeholder.
- À remplacer par un fetch("/api/extract", …) qui appelle votre route serveur (clé OpenAI côté serveur).
- La route renvoie un patch JSON (les champs détectés uniquement).
4) Présentation / récap
- prettySummary(s) : formate un récapitulatif lisible à relire à l’utilisateur avant confirmation.
Le cœur du flux : LogisticsBot
State
- messages : l’historique chat.
- input : champ texte utilisateur.
- data : le JSON en construction (Shipment).
- confirmed : null | true | false (en attente / confirmé / à corriger).
- pendingRef : drapeau “en cours de traitement”.
1) Saisie utilisateur → handleUserSubmit()
- Empêche les doubles clics.
- Ajoute le message user à l’historique.
-
Extraction :
- Essaie extractWithOpenAI() (patch LLM)
- Fusionne avec naiveExtract() (fallback local)
- Cas spécial “non” pour notes (permet de passer le slot optionnel)
- Met à jour data via deepMerge.
- Si tout est rempli et pas encore confirmé → envoie le récap (état SUMMARY) et s’arrête.
- Sinon → pose la prochaine question manquante via nextQuestion().
2) Trouver la “prochaine question” → nextQuestion(s)
-
Mappe chaque key logique à une condition de manque :
- ex. package.dimensions signifie “width_cm, length_cm, height_cm doivent exister”.
- Prend la première question non remplie, selon l’ordre de QUESTIONS.
- Retourne un libellé clair au client.
3) Confirmation finale → useEffect
-
Sur chaque changement, si tout est rempli et confirmed === null :
-
Si l’utilisateur répond “oui / ok / validé” → confirmed=true, on répond “demande enregistrée ✅”
- C’est ici que vous envoyez data vers votre backend (création du sale_order, génération track_link, etc.).
- Si l’utilisateur répond “non” → confirmed=false, bot : “Que souhaitez-vous modifier ?” (on reste en mode correction : les prochaines réponses produiront de nouveaux patch qui seront re-fusionnés).
-
Si l’utilisateur répond “oui / ok / validé” → confirmed=true, on répond “demande enregistrée ✅”
4) Rendu JSX
- Un conteneur chat avec stylage Tailwind très simple.
- Un champ texte + bouton “Envoyer”.
- Un panneau <details> montrant le JSON en temps réel (utile pour QA / démo).
Points d’attention / bonnes pratiques
- Robustesse : toujours fusionner patch par patch pour ne jamais écraser un champ déjà saisi.
- Idempotence : l’utilisateur peut revenir en arrière (“finalement 50×30×25 cm”). Le prochain patch remplacera les dimensions sans casser le reste.
- Accessibilité : gérer Enter pour envoyer, focus clair sur l’input, aria-labels si nécessaire.
- i18n : les messages sont en FR; vous pouvez extraire les textes dans un fichier de locale (FR/MG).
- Validation côté serveur (Zod déjà proposé dans l’étape 5) pour éviter les incohérences.
- Unités & formats : le prompt impose cm/kg et ISO-8601. Vous pouvez ajouter des helpers côté front pour formater à l’affichage (ex. fuseau horaire).
- Sécurité : la clé OpenAI n’apparaît jamais dans ce composant; elle reste sur la route backend.
-
Extensibilité :
- Ajoutez des slots (ex. “valeur déclarée”, “assurance”, “contenu dangereux ?”) → une ligne dans QUESTIONS + une condition dans need(k).
- Ajoutez une géolocalisation
Où brancher Odoo ?
-
Dans l’useEffect au moment de la confirmation (cas “oui”), appelez votre backend :
- Créer le Sale Order (ou Opportunité CRM + Devis).
- Générer track_link (et user_link si nécessaire).
- Sauvegarder l’objet Shipment complet.
- Répondre au front avec l’ID / numéro de commande → afficher un message “commande #SO… créée”.
REPETITION ( pour avoir une vue code en mode plein écran et non en colonne, >du Composant React (TypeScript) – Chat minimal + FSM + hook OpenAI
Design simple (Tailwind), slot-filling robuste.
La fonction extractWithOpenAI est isolée : branchez-la sur votre backend/proxy qui appelle l’API OpenAI.
import React, { useMemo, useRef, useState } from "react"; type Shipment = { sale_order?: string; sender?: { name?: string; phone?: string; email?: string }; delivery_freelance?: string; delivery_site?: string; package_type?: string; user_link?: string; track_link?: string; package?: { width_cm?: number|null; length_cm?: number|null; height_cm?: number|null; weight_kg?: number|null }; pickup?: { address?: string; notes?: string; earliest_datetime?: string; latest_datetime?: string }; dropoff?: { address?: string; deadline_datetime?: string }; urgency?: "faible"|"normal"|"élevé"; extra?: Record<string, any>; }; type Msg = { role: "bot" | "user"; text: string }; const INITIAL_PROMPTS = [ "Bonjour 👋 Je vous aide à créer votre demande d’expédition.", "D’où récupérons-nous le colis ? (adresse complète)" ]; const QUESTIONS = [ { key: "pickup.address", q: "D’où récupérons-nous le colis ? (adresse complète)" }, { key: "dropoff.address", q: "Où livrons-nous ? (adresse complète)" }, { key: "package_type", q: "Quel type de colis ? (ex : carton, enveloppe, palette)" }, { key: "package.dimensions", q: "Quelles dimensions (cm) ? largeur × longueur × hauteur (ex : 40×30×20)" }, { key: "package.weight_kg", q: "Quel poids (kg) ?" }, { key: "pickup.window", q: "Quand pouvons-nous récupérer le colis ? Donnez un créneau (ex : 2025-09-23 09:00–12:00)" }, { key: "dropoff.deadline", q: "Date/heure limite souhaitée pour la livraison ? (ex : 2025-09-26 17:00)" }, { key: "urgency", q: "Niveau d’urgence ? (faible / normal / élevé)" }, { key: "sender", q: "Nom + mobile + e-mail du contact expéditeur ?" }, { key: "pickup.notes", q: "Des instructions particulières ? (optionnel) Sinon dites « non »." } ]; function hasAllRequired(s: Shipment): boolean { const p = s.package || {}; return Boolean( s.pickup?.address && s.dropoff?.address && s.package_type && p.width_cm && p.length_cm && p.height_cm && p.weight_kg && s.pickup?.earliest_datetime && s.pickup?.latest_datetime && s.dropoff?.deadline_datetime && s.urgency && s.sender?.name && s.sender?.phone && s.sender?.email ); } /** Utilitaire: fusionne un patch JSON (issu de l’LLM) dans l’état courant */ 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; } /** Extraction locale simple (regex) pour dimensions et poids, en secours si LLM indisponible */ function naiveExtract(text: string): Partial<Shipment> { const patch: Partial<Shipment> = {}; const dim = text.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i); if (dim) { patch.package = { ...(patch.package || {}), width_cm: +dim[1], length_cm: +dim[2], height_cm: +dim[3] }; } const kg = text.match(/(\d+(?:[.,]\d+)?)\s*kg/i); if (kg) { patch.package = { ...(patch.package || {}), weight_kg: parseFloat(kg[1].replace(",", ".")) }; } return patch; } /** >>> À BRANCHER : appelez votre backend qui appelle OpenAI pour retourner un JSON de patch. */ async function extractWithOpenAI(userText: string, current: Shipment): Promise<Partial<Shipment>> { // Exemple: POST vers votre route /api/extract qui encapsule votre clé OpenAI côté serveur. // 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(); return {}; // placeholder si non branché } function prettySummary(s: Shipment): string { const p = s.package!; return [ "Voici votre récapitulatif :", `• Ramassage : ${s.pickup?.address} (créneau ${s.pickup?.earliest_datetime} – ${s.pickup?.latest_datetime})`, `• Livraison : ${s.dropoff?.address} (avant ${s.dropoff?.deadline_datetime})`, `• Colis : ${s.package_type}, ${p.width_cm}×${p.length_cm}×${p.height_cm} cm, ${p.weight_kg} kg`, `• Urgence : ${s.urgency}`, `• Contact expéditeur : ${s.sender?.name}, ${s.sender?.phone}, ${s.sender?.email}`, s.pickup?.notes ? `• Notes : ${s.pickup?.notes}` : `• Notes : —`, "", "Confirmez-vous cette demande d’expédition ? (oui/non)" ].join("\n"); } export default function LogisticsBot() { const [messages, setMessages] = useState<Msg[]>(INITIAL_PROMPTS.map(t => ({ role: "bot", text: t }))); const [input, setInput] = useState(""); const [data, setData] = useState<Shipment>({ package: {}, pickup: {}, dropoff: {}, sender: {}, extra: {} }); const [confirmed, setConfirmed] = useState<null|boolean>(null); const pendingRef = useRef(false); async function handleUserSubmit() { if (!input.trim() || pendingRef.current) return; const userText = input.trim(); setMessages(m => [...m, { role: "user", text: userText }]); setInput(""); pendingRef.current = true; // 1) Extraire via OpenAI (si branché) + fallback regex let patch: Partial<Shipment> = {}; try { patch = await extractWithOpenAI(userText, data); } catch {} patch = deepMerge(naiveExtract(userText), patch); if (userText.toLowerCase() === "non" && (data.pickup?.notes ?? "") === "") { patch = deepMerge(patch, { pickup: { notes: "" } }); } const next = deepMerge(data, patch); setData(next); // 2) Si tout est rempli et pas encore confirmé => récapitulatif if (hasAllRequired(next) && confirmed === null) { setMessages(m => [...m, { role: "bot", text: prettySummary(next) }]); pendingRef.current = false; return; } // 3) Sinon, poser la prochaine question manquante const ask = nextQuestion(next); setMessages(m => [...m, { role: "bot", text: ask }]); pendingRef.current = false; } function nextQuestion(s: Shipment): string { const p = s.package || {}; const need = (k: string) => { switch (k) { case "pickup.address": return !s.pickup?.address; case "dropoff.address": return !s.dropoff?.address; case "package_type": return !s.package_type; case "package.dimensions": return !(p.width_cm && p.length_cm && p.height_cm); case "package.weight_kg": return !p.weight_kg; case "pickup.window": return !(s.pickup?.earliest_datetime && s.pickup?.latest_datetime); case "dropoff.deadline": return !s.dropoff?.deadline_datetime; case "urgency": return !s.urgency; case "sender": return !(s.sender?.name && s.sender?.phone && s.sender?.email); case "pickup.notes": return s.pickup?.notes === undefined; default: return false; } }; const q = QUESTIONS.find(q => need(q.key))?.q; return q || "Je traite vos informations…"; } // Gestion de la confirmation finale React.useEffect(() => { const last = messages[messages.length - 1]?.text?.toLowerCase?.(); if (hasAllRequired(data) && confirmed === null && last) { if (["oui", "valide", "ok", "confirmé", "confirme"].some(w => last.includes(w))) { setConfirmed(true); // 👉 Ici, envoyez `data` vers votre backend/Odoo setMessages(m => [...m, { role: "bot", text: "Parfait ✅ Votre demande est enregistrée. Un lien de suivi vous sera communiqué." }]); } else if (["non", "corriger", "modifier"].some(w => last.includes(w))) { setConfirmed(false); setMessages(m => [...m, { role: "bot", text: "D’accord. Dites ce que vous souhaitez modifier." }]); } } }, [messages, data, confirmed]); const jsonPretty = useMemo(() => JSON.stringify(data, null, 2), [data]); return ( <div className="w-full max-w-2xl mx-auto p-4"> <div className="border rounded-2xl p-4 h-[540px] overflow-y-auto space-y-3 shadow-sm bg-white"> {messages.map((m, i) => ( <div key={i} className={`whitespace-pre-wrap ${m.role === "bot" ? "text-gray-900" : "text-blue-900 text-right"}`}> <span className={`inline-block rounded-2xl px-3 py-2 ${m.role === "bot" ? "bg-gray-100" : "bg-blue-50"}`}>{m.text}</span> </div> ))} </div> <div className="mt-3 flex gap-2"> <input className="flex-1 border rounded-xl px-3 py-2 outline-none" placeholder="Écrivez votre réponse…" value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleUserSubmit()} /> <button onClick={handleUserSubmit} className="px-4 py-2 rounded-xl bg-black text-white">Envoyer</button> </div> <details className="mt-4"> <summary className="cursor-pointer text-sm text-gray-600">Voir le JSON en temps réel</summary> <pre className="mt-2 text-xs bg-gray-50 p-3 rounded-lg">{jsonPretty}</pre> </details> </div> ); }
5) Route backend (exemple) pour l’extraction via OpenAI
Conservez votre clé OpenAI côté serveur. Cette route reçoit userText + current et renvoie un patch JSON (champs détectés seulement).
// /api/extract (Node/Express ou Next.js API Route) import type { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const { userText, current } = await req.json(); const system = `Vous êtes un extracteur de variables pour un bot logistique... (reprenez mot pour mot le system prompt donné plus haut)`; const user = `Texte utilisateur: "${userText}"\nSchéma partiel attendu: { ... }`; // ⚠️ Implémentez ici l'appel à l’API OpenAI de votre choix. // Important : le modèle doit répondre STRICTEMENT en JSON. // Parsez le JSON et renvoyez-le tel quel en réponse. // return new Response(JSON.stringify(patch), { status: 200 }); return new Response(JSON.stringify({}), { status: 200 }); // placeholder }
Code
// /api/extract (Node/Express ou Next.js API Route) import type { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const { userText, current } = await req.json(); const system = `Vous êtes un extracteur de variables pour un bot logistique... (reprenez mot pour mot le system prompt donné plus haut)`; const user = `Texte utilisateur: "${userText}"\nSchéma partiel attendu: { ... }`; // ⚠️ Implémentez ici l'appel à l’API OpenAI de votre choix. // Important : le modèle doit répondre STRICTEMENT en JSON. // Parsez le JSON et renvoyez-le tel quel en réponse. // return new Response(JSON.stringify(patch), { status: 200 }); return new Response(JSON.stringify({}), { status: 200 }); // placeholder
Voici comment fonctionne (et comment finaliser) votre route backend /api/extract pas à pas, puis une version prête à l’emploi (Next.js) avec l’API recommandée aujourd’hui par OpenAI.
1) Ce que fait chaque ligne (votre snippet)
import type { NextRequest } from "next/server";
- Vous indiquez qu’il s’agit d’une route App Router (Next.js). NextRequest représente la requête entrante HTTP.
export async function POST(req: NextRequest) { const { userText, current } = await req.json();
- Déclare le handler POST.
-
On lit le body JSON :
- userText = dernière phrase tapée par l’utilisateur.
- current = état partiel déjà collecté (votre JSON « Shipment » en cours).
const system = `...`; const user = `...`;
-
Vous préparez les prompts :
- system : rôle et règles strictes (JSON only, unités, ISO…).
- user : message utilisateur + rappel du schéma attendu (patch).
// ⚠️ Implémentez ici l'appel à l’API OpenAI... // return new Response(JSON.stringify(patch), { status: 200 }); return new Response(JSON.stringify({}), { status: 200 }); }
- À cet endroit, vous devez appeler l’API OpenAI, récupérer la réponse JSON, la parser et la renvoyer.
- Actuellement, c’est un placeholder qui renvoie {}.
En clair : cette route reçoit un texte libre → appelle OpenAI → renvoie un patch JSON (champs détectés seulement) que le front fusionnera dans l’état.
2) Quelle API OpenAI utiliser aujourd’hui ?
OpenAI recommande désormais d’utiliser la Responses API (évolution de Chat Completions) ; elle gère nativement le JSON/structured outputs. Pour un extracteur de champs, c’est parfait. OpenAI+2OpenAI+2
3) Version complète (Next.js App Router) — JSON strict + validation
Copiez ce fichier dans app/api/extract/route.ts :
GROS morceau de code dans zone orange ( dans chapitre suivant
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import OpenAI from "openai"; .....
Pourquoi cette version ?
- Responses API = voie recommandée pour les nouveaux projets ; response_format: { type: "json_object" } force une sortie JSON valide. OpenAI+1
- Zod nettoie/valide le patch pour éviter tout champ inattendu.
- Dates : on complète l’heure si l’utilisateur donne “2025-10-01”.
- Clé OpenAI reste côté serveur.
Docs utiles : Responses API, JSON/structured outputs, migration depuis Chat Completions. OpenAI+2OpenAI+2
4) Comment le front l’utilise
Dans votre composant (étape 4), remplacez le placeholder par :
async function extractWithOpenAI(userText: string, current: Shipment) { const res = await fetch("/api/extract", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userText, current }) }); if (!res.ok) return {}; return res.json(); // patch JSON }
5) Conseils prod (résumé)
- Rate limit et logs (p. ex. limiter à 5 req/min/IP).
- Observabilité : loggez userText, le patch validé, et les erreurs de parsing.
- Tests : des jeux d’essai couvrant les formats ambigus (“40-30-20”, “demain matin”, etc.).
- Évolutions : si vous passez à des sorties typées, voyez “Structured outputs” & “function calling” (map vers un schéma JSON strict). OpenAI+1
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import OpenAI from "openai";
// 1) Client OpenAI (clé côté serveur)
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
// 2) Schéma Zod du PATCH (seuls champs détectés autorisés)
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();
// 3) Prompts
const SYSTEM_PROMPT = `
Vous êtes un extracteur de variables pour un bot logistique.
À partir d’un message utilisateur, renvoyez UNIQUEMENT un patch JSON conforme :
{ 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 cm (ex. "40x30x20" => width_cm:40,length_cm:30,height_cm:20)
- Poids en kg (nombre)
- Datetimes ISO 8601 "YYYY-MM-DDTHH:mm"
- Réponse STRICTEMENT JSON, sans texte explicatif.
`;
const userPrompt = (userText: string, current: unknown) => `
Texte utilisateur:
"""
${userText}
"""
Contexte courant (JSON partiel existant, ne dupliquez pas ce qui est déjà connu):
${JSON.stringify(current ?? {}, null, 2)}
Retournez uniquement les CHAMPS DÉTECTÉS sous forme de patch JSON.
`;
// 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 });
}
// 4.a Appel Responses API en mode JSON structuré
// (Structured outputs / JSON mode — recommandé)
const resp = await client.responses.create({
model: "gpt-4.1-mini", // adapté à l'extraction + coût contenu
temperature: 0,
reasoning: { effort: "low" }, // inutile d'élaborer
input: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt(userText, current) }
],
response_format: { type: "json_object" } // oblige une réponse JSON
});
// 4.b Récupérer le JSON
const content = resp.output_text ?? "{}"; // équivalent pratique
let patch: unknown;
try { patch = JSON.parse(content); } catch { patch = {}; }
// 4.c Validation (rejette tout champ non prévu)
const safe = PatchSchema.parse(patch);
// 4.d Normalisation simple: si date sans heure, ajoute 08:00
const norm = (s?: string) => (s && /^\d{4}-\d{2}-\d{2}$/.test(s)) ? `${s}T08:00` : s;
if (safe.pickup?.earliest_datetime) safe.pickup.earliest_datetime = norm(safe.pickup.earliest_datetime)!;
if (safe.pickup?.latest_datetime) safe.pickup.latest_datetime = norm(safe.pickup.latest_datetime)!;
if (safe.dropoff?.deadline_datetime) safe.dropoff.deadline_datetime = norm(safe.dropoff.deadline_datetime)!;
return NextResponse.json(safe, { status: 200 });
} catch (e: any) {
console.error("extract /api error", e);
return NextResponse.json({ error: e?.message ?? "Erreur serveur" }, { status: 500 });
}
}

Un peu de redondance



6) Notes d’intégration Odoo / tracking
-
Au moment de la confirmation, envoyez data vers votre backend pour :
- créer le sale_order si nécessaire (retournez l’ID → sale_order),
- générer track_link et l’injecter,
- attacher user_link (compte client),
- choisir/affecter delivery_freelance et delivery_site selon votre logique.
ANNEXE
Route backend complète avec :
- appel OpenAI,
- validation Zod (patch strict),
- normalisation (cm/kg, ISO-8601),
- et un exemple cURL pour tester.