'Non identifié — veuillez vous connecter.']); exit; } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Méthode non autorisée']); exit; } const OPENAI_MODEL = 'gpt-5.6-luna'; const OPENAI_CHAT_URL = 'https://api.openai.com/v1/chat/completions'; $data = json_decode(file_get_contents('php://input'), true) ?? []; $texte = trim((string) ($data['texte'] ?? '')); if ($texte === '') { http_response_code(400); echo json_encode(['error' => 'Rien à interpréter']); exit; } // trim() : un espace ou un retour à la ligne resté collé à la valeur en la // collant dans docker-compose/.env (très facile en copiant-collant depuis le // navigateur) suffit à faire échouer l'authentification côté OpenAI avec un // message d'erreur qui ne le laisse pas deviner (la clé "a l'air" correcte). $apiKey = trim((string) getenv('OPENAI_API_KEY')); if (!$apiKey) { http_response_code(500); echo json_encode(['error' => "Clé OPENAI_API_KEY absente côté serveur — à ajouter dans l'environnement du conteneur avant de pouvoir tester."]); exit; } try { $pdo = getDbConnection(); } catch (PDOException $e) { http_response_code(500); echo json_encode(['error' => 'Connexion base de données impossible']); exit; } // -------- Listes de référence, relues à chaque appel (voir en-tête) -------- try { $statuts = $pdo->query('SELECT STA_Code, STA_Libellé FROM ASP_Statuts ORDER BY STA_Incr ASC')->fetchAll(); $professions = $pdo->query('SELECT PRF_Incr, PRF_Libelle FROM ASP_Professions ORDER BY PRF_Libelle ASC')->fetchAll(); // Ajout du 4 septembre 2026 (soir), demande de Jean-Marc : deux champs de // plus à interpréter, "à qui s'adresse la dictée" et "quelles actions // sont demandées" — en réutilisant deux tables déjà existantes plutôt // qu'en inventer de nouvelles : ASP_Agents (déjà lue par agents.php pour // le vrai pilotage vocal, mêmes AGN_Variantes tolérées ici) et // ASP_Actions (déjà utilisée par realisations.php pour journaliser une // action faite sur un contact — même liste de référence, réutilisée ici // en amont, comme intention plutôt que comme réalisation déjà faite). // Pas de AGN_Defaut ici : Jean-Marc a précisé (4 septembre 2026, soir) // que le choix de l'assistant par défaut quand personne n'est nommément // cité n'est PAS la responsabilité de ce fichier — c'est le traitement // déclenché par n8n, en aval, qui s'en charge au moment de transformer // une proposition validée (ASP_PropositionsContact) en post-it réel. Ce // fichier reste volontairement passif : il ne renvoie que ce qui a été // réellement dit (assistant_id reste null si personne n'est cité), sans // jamais anticiper une décision qui appartient à l'étage suivant. // AGN_Nom renommée AGN_Prenom en base (7 septembre 2026, nuit). $agents = $pdo->query('SELECT AGN_Incr, AGN_Prenom, AGN_Fonction, AGN_Variantes FROM ASP_Agents ORDER BY AGN_Incr ASC')->fetchAll(); $actionsDisponibles = $pdo->query('SELECT ACT_Code, ACT_Libelle FROM ASP_Actions ORDER BY ACT_Libelle ASC')->fetchAll(); } catch (PDOException $e) { http_response_code(500); echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]); exit; } $codesStatutsValides = array_column($statuts, 'STA_Code'); $idsAgentsValides = array_map('intval', array_column($agents, 'AGN_Incr')); $codesActionsValides = array_column($actionsDisponibles, 'ACT_Code'); // -------- Schéma JSON strict transmis au modèle -------- // additionalProperties:false + tous les champs listés dans "required" (y // compris ceux qui peuvent être null) : forme imposée par le mode "strict" // d'OpenAI pour garantir que la réponse colle exactement au schéma, jamais // un champ en plus ou en moins. $schemaChamps = [ 'CNT_Nom' => ['type' => ['string', 'null']], 'CNT_Prenom' => ['type' => ['string', 'null']], 'CNT_Statut' => ['type' => ['string', 'null'], 'enum' => array_merge($codesStatutsValides, [null])], 'CNT_Société' => ['type' => ['string', 'null']], 'CNT_Service' => ['type' => ['string', 'null']], 'CNT_Fonction' => ['type' => ['string', 'null']], 'CNT_AdrLig1' => ['type' => ['string', 'null']], 'CNT_AdrLig2' => ['type' => ['string', 'null']], 'CNT_AdrLig3' => ['type' => ['string', 'null']], 'CNT_AdrCP' => ['type' => ['string', 'null']], 'CNT_AdrVille' => ['type' => ['string', 'null']], 'CNT_SIRET' => ['type' => ['string', 'null']], 'CNT_Tel' => ['type' => ['string', 'null']], 'CNT_Portable' => ['type' => ['string', 'null']], 'CNT_Mail' => ['type' => ['string', 'null']], 'CNT_Notes' => ['type' => ['string', 'null']], ]; $schema = [ 'type' => 'object', 'properties' => [ 'champs' => [ 'type' => 'object', 'properties' => $schemaChamps, 'required' => array_keys($schemaChamps), 'additionalProperties' => false, ], 'profession_existante_id' => [ 'type' => ['integer', 'null'], 'description' => "PRF_Incr d'une profession de la liste fournie, UNIQUEMENT si elle correspond clairement à ce qui a été dicté.", ], 'profession_nouvelle_libelle' => [ 'type' => ['string', 'null'], 'description' => "Libellé d'une profession dictée qui ne correspond à AUCUNE profession existante de la liste fournie. Toujours null si profession_existante_id est renseigné.", ], 'assistant_id' => [ 'type' => ['integer', 'null'], 'enum' => array_merge($idsAgentsValides, [null]), 'description' => "AGN_Incr de l'assistant nommément désigné par la dictée (ex. \"Matilda, ajoute...\"), UNIQUEMENT s'il est clairement nommé. null si aucun assistant n'est nommé — ne jamais supposer un assistant par défaut.", ], 'actions' => [ 'type' => 'array', 'description' => "Chaque action demandée explicitement dans la dictée (ex. créer/ajouter le contact, envoyer une documentation, envoyer une proposition tarifaire...). Tableau vide si aucune action explicite n'est demandée. La création/l'ajout du contact lui-même, quand elle est demandée, est toujours le premier élément du tableau (libellé fixe \"Ajouter le contact\" en action_nouvelle_libelle si aucun code existant ne correspond) — voir les instructions détaillées plus bas.", 'items' => [ 'type' => 'object', 'properties' => [ 'action_existante_code' => [ 'type' => ['string', 'null'], 'enum' => array_merge($codesActionsValides, [null]), ], 'action_nouvelle_libelle' => ['type' => ['string', 'null']], ], 'required' => ['action_existante_code', 'action_nouvelle_libelle'], 'additionalProperties' => false, ], ], ], 'required' => ['champs', 'profession_existante_id', 'profession_nouvelle_libelle', 'assistant_id', 'actions'], 'additionalProperties' => false, ]; // -------- Prompt -------- $listeStatuts = $statuts ? implode(', ', array_map(fn($s) => $s['STA_Code'] . ' (' . $s['STA_Libellé'] . ')', $statuts)) : '(aucun statut configuré)'; $listeProfessions = $professions ? implode(', ', array_map(fn($p) => $p['PRF_Incr'] . ' = ' . $p['PRF_Libelle'], $professions)) : '(aucune profession existante)'; $listeAgents = $agents ? implode(', ', array_map( fn($a) => $a['AGN_Incr'] . ' = ' . $a['AGN_Prenom'] . ($a['AGN_Variantes'] ? ' (variantes tolérées : ' . $a['AGN_Variantes'] . ')' : ''), $agents )) : '(aucun assistant configuré)'; $listeActions = $actionsDisponibles ? implode(', ', array_map(fn($a) => $a['ACT_Code'] . ' = ' . $a['ACT_Libelle'], $actionsDisponibles)) : '(aucune action de référence configurée)'; // Texte sorti du code le 11 septembre 2026 — voir moteur_prompts.php pour le // texte par défaut/la table ASP_Prompts, bo_prompts.html pour l'édition. try { $promptSysteme = chargerPromptSysteme($pdo, 'CONTACT_IA_INTERPRETE', [ '{{LISTE_STATUTS}}' => $listeStatuts, '{{LISTE_PROFESSIONS}}' => $listeProfessions, '{{LISTE_AGENTS}}' => $listeAgents, '{{LISTE_ACTIONS}}' => $listeActions, ]); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Prompt système indisponible : ' . $e->getMessage()]); exit; } // -------- Appel OpenAI -------- $corpsRequete = [ 'model' => OPENAI_MODEL, 'messages' => [ ['role' => 'system', 'content' => $promptSysteme], ['role' => 'user', 'content' => $texte], ], 'response_format' => [ 'type' => 'json_schema', 'json_schema' => [ 'name' => 'extraction_contact', 'strict' => true, 'schema' => $schema, ], ], // Ni temperature ni max_completion_tokens fixés volontairement : la // famille de modèles gpt-5.6 a changé plusieurs fois la prise en charge // de ces paramètres selon la variante — mieux vaut s'en tenir aux // valeurs par défaut tant que le premier essai réel n'a pas confirmé ce // qui est accepté, plutôt que de risquer une requête rejetée pour un // paramètre mal supporté. ]; $ch = curl_init(OPENAI_CHAT_URL); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($corpsRequete), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ], CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, ]); $reponseBrute = curl_exec($ch); $erreurCurl = curl_error($ch); $codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($reponseBrute === false) { http_response_code(502); echo json_encode(['error' => "Appel à l'API OpenAI impossible : " . $erreurCurl]); exit; } $reponseDecodee = json_decode($reponseBrute, true); if ($codeHttp !== 200) { http_response_code(502); echo json_encode([ 'error' => "L'API OpenAI a renvoyé une erreur (HTTP $codeHttp) : " . ($reponseDecodee['error']['message'] ?? $reponseBrute), ]); exit; } $contenuJson = $reponseDecodee['choices'][0]['message']['content'] ?? null; if (!$contenuJson) { http_response_code(502); echo json_encode(['error' => "Réponse OpenAI inattendue (pas de contenu) : " . $reponseBrute]); exit; } $extraction = json_decode($contenuJson, true); if ($extraction === null) { http_response_code(502); echo json_encode(['error' => "Le contenu renvoyé par OpenAI n'est pas un JSON valide : " . $contenuJson]); exit; } // Garde-fous défensifs : le mode strict d'OpenAI est censé garantir des // valeurs valides (CNT_Statut, assistant_id, codes d'action), mais on ne // fait jamais confiance aveuglément à un service externe pour la suite // (voir contacts.php, même principe déjà appliqué à CNT_Statut). if (!empty($extraction['champs']['CNT_Statut']) && !in_array($extraction['champs']['CNT_Statut'], $codesStatutsValides, true)) { $extraction['champs']['CNT_Statut'] = null; } if (!empty($extraction['assistant_id']) && !in_array((int) $extraction['assistant_id'], $idsAgentsValides, true)) { $extraction['assistant_id'] = null; } foreach ($extraction['actions'] ?? [] as &$action) { if (!empty($action['action_existante_code']) && !in_array($action['action_existante_code'], $codesActionsValides, true)) { $action['action_existante_code'] = null; } } unset($action); echo json_encode([ 'extraction' => $extraction, 'statuts' => $statuts, 'professions' => $professions, 'agents' => $agents, 'actionsDisponibles' => $actionsDisponibles, ]);