mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
388 lines
19 KiB
PHP
388 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* ASPIC — contact_ia_interprete.php
|
|
*
|
|
* Outil de mise au point interne (4 septembre 2026, demande explicite de
|
|
* Jean-Marc) : PAS un endpoint destiné aux futurs utilisateurs à ce stade.
|
|
* Prolonge la banque de dictées de dictees_test.php/micro_test.js — jusqu'ici
|
|
* la dictée était seulement enregistrée brute, sans aucune interprétation
|
|
* (voir réflexions stratégiques, section 12 : "constituer d'abord un corpus
|
|
* réel avant de concevoir les règles"). Avec ce fichier, on franchit l'étape
|
|
* suivante pour un seul périmètre volontairement restreint — la création
|
|
* d'un contact — en s'appuyant sur un LLM à sortie structurée plutôt que sur
|
|
* des règles écrites à la main (rejetées pour ce problème dans les
|
|
* réflexions stratégiques : trop de variabilité dans les formulations
|
|
* réelles pour un empilement de règles/regex).
|
|
*
|
|
* Reçoit un texte dicté libre (POST, corps JSON {"texte": "..."}) et renvoie
|
|
* les champs de ASP_Contacts qu'un modèle OpenAI (function calling / sortie
|
|
* structurée JSON) a pu en extraire — laissés à null quand rien de fiable
|
|
* n'a été trouvé, jamais devinés. Ce fichier NE CRÉE JAMAIS de contact
|
|
* lui-même : c'est le rôle de contacts.php (déjà en production), appelé
|
|
* séparément par le front-end une fois que Jean-Marc a vérifié/complété les
|
|
* champs à l'écran — voir contact_ia_test.html/.js.
|
|
*
|
|
* Choix d'architecture :
|
|
* - Un seul appel LLM par interprétation, pas de session/état conservé ici.
|
|
* - Les listes de référence (ASP_Statuts, ASP_Professions) sont relues à
|
|
* chaque appel et injectées dans le prompt — jamais recopiées en dur dans
|
|
* ce fichier, même principe que AGN_Variantes/AGN_Defaut pour les agents
|
|
* vocaux (piloté en base, pas figé dans le code).
|
|
* - CNT_Statut doit être un STA_Code réellement existant (ou null) : imposé
|
|
* par une énumération dans le schéma JSON transmis au modèle, jamais une
|
|
* valeur inventée.
|
|
* - CNT_CodeProfession (clé vers ASP_Professions, liste OUVERTE contrairement
|
|
* à ASP_Statuts — voir professions.php) ne peut pas être imposé par
|
|
* énumération de la même façon : le modèle renvoie soit l'identifiant
|
|
* d'une profession existante si elle correspond clairement, soit un
|
|
* libellé de profession nouvelle en toutes lettres si aucune ne
|
|
* correspond — c'est au front-end de créer cette profession (POST
|
|
* professions.php, déjà utilisé ailleurs pour "+ Nouvelle profession…")
|
|
* avant de créer le contact, jamais à ce fichier.
|
|
* - Tout ce qui est dit et ne correspond à aucun champ structuré n'est pas
|
|
* perdu : renvoyé dans CNT_Notes (résumé concis, pas nécessairement mot
|
|
* pour mot) — cohérent avec la règle déjà en place ailleurs dans le
|
|
* projet ("aucun essai raté supprimé", rien n'est jeté).
|
|
*
|
|
* Clé d'API : lue via getenv('OPENAI_API_KEY'), jamais en dur dans le code —
|
|
* même convention que DB_USER/DB_PASS dans db.php (variable d'environnement
|
|
* posée côté docker-compose sur le VPS, ajoutée par Jean-Marc le 4 septembre
|
|
* 2026). Si absente, l'endpoint répond 500 avec un message explicite plutôt
|
|
* que d'échouer silencieusement.
|
|
*
|
|
* ⚠️ Point non vérifié en conditions réelles : cette session n'a pas la main
|
|
* pour exécuter un vrai appel à l'API OpenAI (pas de clé disponible ici,
|
|
* jamais transmise — voir échange avec Jean-Marc). L'appel ci-dessous suit
|
|
* la documentation OpenAI (endpoint Chat Completions, response_format en
|
|
* json_schema strict) au 4 septembre 2026, mais mérite un premier essai réel
|
|
* pour confirmer que la forme de la requête/réponse n'a pas divergé entre
|
|
* temps. Si le premier essai échoue, le message d'erreur renvoyé par
|
|
* OpenAI est transmis tel quel au front-end (voir gérerErreurOpenAI) pour
|
|
* pouvoir corriger vite sans deviner.
|
|
*
|
|
* Modèle utilisé : constante OPENAI_MODEL ci-dessous, volontairement isolée
|
|
* pour pouvoir monter en gamme (gpt-5.6-terra, plus capable mais plus cher —
|
|
* voir comparatif tarifaire du 4 septembre) sans toucher au reste du fichier
|
|
* si la qualité d'extraction sur gpt-5.6-luna se révèle insuffisante à
|
|
* l'usage.
|
|
*
|
|
* ⚠️ Session utilisateur : accès réservé aux utilisateurs identifiés
|
|
* (`$_SESSION['USR_Code']`, ouverte par aspic.php), même principe que les
|
|
* autres endpoints (dictees_test.php, todolist.php...).
|
|
*/
|
|
|
|
session_start();
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
require __DIR__ . '/db.php';
|
|
|
|
if (empty($_SESSION['USR_Code'])) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => '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)';
|
|
|
|
$promptSysteme = <<<PROMPT
|
|
Tu extrais des informations de contact professionnel (prospection commerciale
|
|
d'un CRM français) à partir d'une dictée vocale libre en français, brute et
|
|
parfois hésitante (hésitations, reprises, "euh", phrases inachevées).
|
|
|
|
Règles impératives :
|
|
- N'invente RIEN. Un champ non mentionné clairement, ou ambigu, reste null.
|
|
- Ne devine jamais une orthographe que tu ne peux pas déduire du texte fourni.
|
|
- CNT_Statut : uniquement l'un de ces codes exacts, ou null si aucun statut
|
|
n'est évoqué : {$listeStatuts}
|
|
- Profession : compare ce qui est dicté à cette liste existante (id = libellé) :
|
|
{$listeProfessions}
|
|
Si une correspondance claire existe, renvoie profession_existante_id (et
|
|
laisse profession_nouvelle_libelle à null). Sinon, si une profession est
|
|
bien mentionnée mais absente de la liste, renvoie son libellé dans
|
|
profession_nouvelle_libelle (et laisse profession_existante_id à null). Si
|
|
aucune profession n'est mentionnée, les deux restent null.
|
|
- CNT_Notes : tout ce qui est dit et utile au contact mais qui ne correspond
|
|
à aucun champ structuré ci-dessus (contexte de rencontre, centres
|
|
d'intérêt, demande explicite comme "envoyer la doc", remarques...) — un
|
|
résumé concis en français, pas nécessairement mot pour mot. Ne recopie pas
|
|
dans les notes une information déjà correctement placée dans un champ.
|
|
- Un numéro de téléphone fixe va dans CNT_Tel, un mobile dans CNT_Portable ;
|
|
si le type n'est pas précisé et qu'un seul numéro est dicté, mets-le dans
|
|
CNT_Tel.
|
|
- CNT_Mail : une adresse mail ne contient JAMAIS d'accent (é, è, à, ç, ï...).
|
|
Si la transcription dictée en laisse apparaître un dans ce qui doit devenir
|
|
une adresse mail, retire l'accent (é→e, è→e, à→a...) plutôt que de le
|
|
recopier tel quel — ne jamais produire une adresse mail accentuée.
|
|
- assistant_id : l'assistant explicitement nommé par la dictée, parmi cette
|
|
liste (id = nom) : {$listeAgents}
|
|
Ne renvoie un id que si un de ces noms (ou une de ses variantes tolérées)
|
|
est clairement prononcé. Aucun assistant nommé → null, ne suppose JAMAIS un
|
|
assistant par défaut.
|
|
- actions : identifie chaque action explicitement demandée dans la dictée
|
|
(ex. "il faut lui envoyer la doc", "envoyer une proposition de tarif",
|
|
"ajoute ce contact"...). Compare à cette liste existante (code = libellé) :
|
|
{$listeActions}
|
|
Pour chaque action reconnue, renvoie action_existante_code si elle
|
|
correspond clairement à un code de cette liste (action_nouvelle_libelle
|
|
reste null), sinon renvoie son libellé en français dans
|
|
action_nouvelle_libelle (action_existante_code reste null). Un tableau vide
|
|
si aucune action n'est explicitement demandée — ne déduis jamais une action
|
|
qui ne serait qu'implicite ou probable.
|
|
La création/l'ajout du contact lui-même ("ajoute ce contact", "crée-le",
|
|
"enregistre ce contact"...) est une action à part entière au même titre
|
|
que les autres, même si elle n'a pas de code dans la liste ci-dessus —
|
|
utilise alors exactement le libellé "Ajouter le contact" dans
|
|
action_nouvelle_libelle (formulation fixe, pour rester comparable d'une
|
|
dictée à l'autre). Quand elle est demandée, place-la TOUJOURS en premier
|
|
dans le tableau actions, avant les autres actions éventuelles (envoyer une
|
|
doc, etc.) : c'est en général la première chose voulue chronologiquement.
|
|
PROMPT;
|
|
|
|
// -------- 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,
|
|
]);
|