V1.012 Début du traitement par IA des retranscriptions des dictées

This commit is contained in:
2026-09-05 05:44:30 +00:00
parent 2a7d257563
commit ba8903d87b
10 changed files with 1975 additions and 22 deletions
+386
View File
@@ -0,0 +1,386 @@
<?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.
$agents = $pdo->query('SELECT AGN_Incr, AGN_Nom, 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_Nom'] .
($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,
]);