Files
ASPIC/contact_ia_interprete.php
T

350 lines
16 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';
require __DIR__ . '/moteur_prompts.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)';
// 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,
]);