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,
]);
+847
View File
@@ -0,0 +1,847 @@
/**
* ASPIC — contact_ia_panel.js
*
* Outil de mise au point interne (4 septembre 2026, soir) — PAS encore un
* module utilisateur final, mais la première fois que l'interprétation IA
* d'une dictée de contact (voir contact_ia_interprete.php/contact_ia_valide.php)
* est branchée directement sur le micro du bureau (#micro-desk), à la place
* de micro_test.js — décision explicite de Jean-Marc : la banque de dictées
* brutes (micro_test.js/dictees_test.php, ASP_DicteesTest) a rempli son rôle
* de constitution de corpus et "ne sert plus à rien" maintenant que
* l'interprétation elle-même fonctionne. micro_test.js n'est pas supprimé
* (garde son utilité d'archive/référence), juste retiré du script chargé en
* bas d'ecran_fond.html.
*
* Reprend la quasi-totalité de la logique de contact_ia_test.js (identifiants
* data-champ, appels aux mêmes endpoints, mêmes fonctions de remplissage) —
* seule la construction du DOM change : un panneau flottant construit en JS
* (même principe que la fenêtre de micro_test.js), pas une page HTML dédiée.
* contact_ia_test.html/.js restent en place tels quels (page autonome, plus
* très utile maintenant mais rien ne l'interdit de la garder) : ce fichier
* ne les modifie pas.
*
* Différences de présentation demandées par Jean-Marc (4 septembre 2026,
* soir) par rapport à contact_ia_test.html :
* - Panneau qui ne prend pas tout l'écran (comme la fenêtre de micro_test.js),
* pas une page à part entière.
* - Champs "bruts" : nom du champ suivi de sa valeur, police terminal,
* plutôt que des blocs/cartes stylées avec grille — effet "outil d'expert"
* voulu explicitement.
* - Pas de texte d'explication en haut : remplacé par une ligne façon prompt
* SSH ("user@<prénom> : ", blanc sur fond noir). Le prénom vient de
* preferences.php (USR_Prenom, déjà utilisé ailleurs sur cet écran pour le
* chevalet) plutôt que d'être écrit en dur — même principe que pour les
* noms d'agents (jamais figer un nom précis dans le code).
*
* Contrairement à micro_test.js, le panneau NE SE FERME PAS quand le micro
* s'arrête tout seul (redémarrage automatique compris) : seule une action
* explicite de Jean-Marc (Valider, ou Échap — voir troisième vague plus bas)
* referme le panneau.
*
* Deuxième vague de demandes de présentation (4 septembre 2026, encore le
* même soir) :
* - Le panneau grandit vers le bas, mais jamais au-delà du pied réel du
* micro sur le bureau (silhouette complète de #micro-desk, voir
* ecran_fond.html/MICRO_ZONE_Y2) — recalculé à chaque ouverture et au
* redimensionnement de la fenêtre (voir ajusterHauteurPanneau), en lisant
* directement la position réelle de #micro-desk (getBoundingClientRect)
* plutôt qu'en dupliquant ici la géométrie de l'image de fond.
* - Séquence d'ouverture façon terminal : le prompt "user@<prénom> : "
* s'affiche d'abord seul avec un petit curseur "_" clignotant (une seconde
* et demie), puis le curseur est remplacé par "micro ouvert", et une
* deuxième ligne de prompt apparaît en dessous : "user@<prénom> : parlez !"
* (voir demarrerSequencePrompt/rafraichirTextePrompt).
* - Plus de bouton "Interpréter" ni "Réinitialiser" : l'interprétation part
* automatiquement dès que le micro s'arrête proprement (clic volontaire de
* Jean-Marc, pas un arrêt dû à une erreur bloquante — voir
* lancerInterpretation, appelée depuis l'événement "end" de la
* reconnaissance). Un statut "Interprétation en cours…" s'affiche dans la
* même zone que la dictée brute et les erreurs éventuelles, le temps de
* l'appel.
* - Plus de croix de fermeture séparée : "Valider" referme le panneau après
* confirmation de l'enregistrement (le temps de lire le message).
*
* Troisième vague (4 septembre 2026, encore le même soir) :
* - Bouton "Abandonner" retiré : il faisait double emploi avec la touche
* Échap, qui reste l'unique façon d'abandonner l'essai en cours sans rien
* enregistrer ("tant pis si on perd des data", voir abandonnerPanneau et
* le gestionnaire "keydown" plus bas). "Valider" reste seul, en pleine
* largeur.
* - 3 zones de texte libre pour les actions (plus la liste dynamique
* "+ Ajouter une action") — voir remplirActions/collecterActionsValidees.
*/
(function(){
"use strict";
const boutonMicro = document.getElementById("micro-desk");
if(!boutonMicro) return; // ce script ne concerne que l'écran de fond
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
if(!ReconnaissanceVocale){
boutonMicro.title = "Reconnaissance vocale non disponible sur ce navigateur";
return;
}
const reconnaissance = new ReconnaissanceVocale();
reconnaissance.lang = "fr-FR";
reconnaissance.continuous = true;
reconnaissance.interimResults = true;
reconnaissance.maxAlternatives = 1;
let ecouteVoulue = false;
let erreurBloquante = false;
let ligneEnCours = null;
let texteComplet = "";
let derniereExtraction = null;
const DUREE_INACTIVITE_MS = 10 * 60 * 1000;
let minuteurInactivite = null;
function reinitialiserMinuteurInactivite(){
if(minuteurInactivite) clearTimeout(minuteurInactivite);
minuteurInactivite = setTimeout(function(){
if(!ecouteVoulue) return;
ecouteVoulue = false;
reconnaissance.stop();
}, DUREE_INACTIVITE_MS);
}
function arreterMinuteurInactivite(){
if(minuteurInactivite){ clearTimeout(minuteurInactivite); minuteurInactivite = null; }
}
// Silence prolongé = fin de la dictée (demande de Jean-Marc, 4 septembre
// 2026, encore le même soir) : "un silence de 3-4 secondes vaut arrêt des
// opérations" — 3,5s de compromis. Armé seulement à partir du premier
// résultat reçu (voir l'événement "result" plus bas), pas dès l'ouverture
// du micro : sinon un temps de réflexion avant de commencer à parler
// couperait le micro tout seul. Un arrêt par silence est traité exactement
// comme un clic volontaire sur le micro (ecouteVoulue = false puis
// reconnaissance.stop()) : ça déclenche l'interprétation automatique au
// même titre (voir l'événement "end").
const DUREE_SILENCE_ARRET_MS = 3500;
let minuteurSilence = null;
function reinitialiserMinuteurSilence(){
if(minuteurSilence) clearTimeout(minuteurSilence);
minuteurSilence = setTimeout(function(){
if(!ecouteVoulue) return;
ecouteVoulue = false;
reconnaissance.stop();
}, DUREE_SILENCE_ARRET_MS);
}
function arreterMinuteurSilence(){
if(minuteurSilence){ clearTimeout(minuteurSilence); minuteurSilence = null; }
}
// -------- Construction du panneau (une seule fois, comme micro_test.js) --------
let panneau = null, lignePrompt1, promptTexte1, curseurClignotant, lignePrompt2,
promptTexte2, zoneLignes, zoneResultat, boutonValider,
messageCreation, selectStatut, selectProfession, blocNouvelleProfession,
champNouvelleProfession, selectAssistant, champsActions;
function ligneChamp(conteneur, label, nomChamp, multiligne){
const ligne = document.createElement("div");
ligne.className = "ligne-champ";
const lab = document.createElement("span");
lab.className = "label-champ";
lab.textContent = label + " :";
const input = document.createElement(multiligne ? "textarea" : "input");
input.dataset.champ = nomChamp;
ligne.appendChild(lab);
ligne.appendChild(input);
conteneur.appendChild(ligne);
return input;
}
function construirePanneau(){
if(panneau) return;
const style = document.createElement("style");
style.textContent =
"#panneau-contact-ia{position:fixed;top:6%;left:50%;transform:translateX(-50%);" +
"width:min(480px,86vw);background:#000;border:1px solid #333;" +
"border-radius:6px;box-shadow:0 10px 40px rgba(0,0,0,.6);z-index:950;" +
"display:none;flex-direction:column;overflow:hidden;" +
"font-family:'Courier New',Consolas,monospace;color:#fff;}" +
"#panneau-contact-ia.visible{display:flex;}" +
"#panneau-contact-ia .entete-prompt{color:#fff;background:#000;font-size:13px;" +
"line-height:1.6;padding:8px 10px;border-bottom:1px solid #222;}" +
"#panneau-contact-ia #ligne-prompt-2[hidden]{display:none;}" +
"@keyframes clignoter-curseur{0%,49%{opacity:1;}50%,100%{opacity:0;}}" +
"#panneau-contact-ia #curseur-clignotant{animation:clignoter-curseur 1s steps(1) infinite;}" +
"#panneau-contact-ia #curseur-clignotant[hidden]{display:none;}" +
// Une seule zone de défilement pour TOUT le contenu, y compris l'en-
// tête façon prompt — demande de Jean-Marc (4 septembre 2026, encore le
// même soir) : "je veux que l'ascenseur parte du haut de la fenêtre
// même si ça fait disparaître les deux premières lignes de prompt".
// L'en-tête n'est donc plus fixe (flex:none retiré ci-dessus) : il fait
// maintenant partie du flux normal à l'intérieur de .contenu-scrollable,
// qui est l'unique enfant du panneau et en occupe toute la hauteur.
// overflow-y:auto + min-height:0 sur cet enfant flex : le classique
// pour qu'il se réduise réellement au lieu de pousser le panneau à
// s'agrandir.
"#panneau-contact-ia .contenu-scrollable{flex:1;min-height:0;overflow-y:auto;}" +
// Ascenseur personnalisé, plus discret que celui du navigateur par
// défaut (demande de Jean-Marc) : fin, sans piste visible, pouce gris
// sombre qui se fond dans le panneau plutôt que la barre grise claire
// habituelle. scrollbar-width/-color pour Firefox, ::-webkit-scrollbar
// pour Chrome/Edge (les deux couvrent déjà toute la hauteur de
// .contenu-scrollable, qui occupe elle-même tout l'espace disponible
// sous l'en-tête — voir flex:1 juste au-dessus).
"#panneau-contact-ia .contenu-scrollable{scrollbar-width:thin;scrollbar-color:#3a3a3a transparent;}" +
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar{width:6px;}" +
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-track{background:transparent;}" +
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-thumb{" +
"background:#3a3a3a;border-radius:3px;}" +
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-thumb:hover{background:#555;}" +
"#panneau-contact-ia .lignes{padding:8px 10px;" +
"font-size:13px;line-height:1.5;white-space:pre-wrap;border-bottom:1px solid #222;}" +
"#panneau-contact-ia .lignes .en-cours{color:#8a8a8a;font-style:italic;}" +
"#panneau-contact-ia .lignes .erreur{color:#e14a5c;}" +
"#panneau-contact-ia .resultat{padding:8px 10px;display:none;}" +
"#panneau-contact-ia .resultat.visible{display:block;}" +
"#panneau-contact-ia .ligne-champ{display:flex;align-items:baseline;gap:6px;" +
"margin-bottom:5px;font-size:13px;}" +
"#panneau-contact-ia .label-champ{flex:0 0 100px;color:#888;text-align:right;}" +
"#panneau-contact-ia .ligne-champ input,#panneau-contact-ia .ligne-champ select," +
"#panneau-contact-ia .ligne-champ textarea{flex:1;font:inherit;font-size:13px;" +
"background:transparent;color:#fff;border:none;border-bottom:1px solid #333;" +
"padding:2px 0;}" +
"#panneau-contact-ia .ligne-champ input.interprete," +
"#panneau-contact-ia .ligne-champ select.interprete{border-bottom-color:#3a6d3a;}" +
"#panneau-contact-ia .ligne-champ textarea{min-height:40px;resize:vertical;}" +
// Options des listes (Statut, Profession, Assistant) illisibles à
// l'ouverture — blanc sur blanc pour les lignes normales, blanc sur
// bleu (surlignage par défaut du navigateur) pour celle sous le
// pointeur — remonté par Jean-Marc le 4 septembre 2026 (encore le même
// soir) après essai réel. "background/color" sur <option> stylent bien
// les lignes normales dans tous les navigateurs ; ":hover"/":checked"
// sur <option> ne sont fiables qu'avec Firefox (Chrome garde son
// surlignage natif pour la ligne active) — au pire ce surlignage natif
// reste visible, seul le blanc sur blanc des lignes normales était un
// vrai bug bloquant, corrigé ici.
"#panneau-contact-ia .ligne-champ select option{background:#1a1a1a;color:#999;}" +
"#panneau-contact-ia .ligne-champ select option:checked," +
"#panneau-contact-ia .ligne-champ select option:hover{background:#999;color:#111;}" +
// Bouton "Abandonner" retiré (demande de Jean-Marc, 4 septembre 2026,
// encore le même soir) : il faisait double emploi avec la touche Échap
// (voir abandonnerPanneau, toujours utilisée par Échap seule
// désormais). "Valider" reste seul, en pleine largeur.
"#panneau-contact-ia #bouton-valider{display:block;width:100%;margin:12px 0 0;" +
"font:inherit;font-size:13px;background:#2a4d2a;color:#bdf7bd;" +
"border:1px solid #3a6d3a;border-radius:5px;padding:8px 16px;cursor:pointer;}" +
"#panneau-contact-ia #bouton-valider:disabled{opacity:.4;cursor:default;}" +
"#panneau-contact-ia #message-creation{margin-top:8px;font-size:12px;}" +
"#panneau-contact-ia #message-creation.ok{color:#7be07b;}" +
"#panneau-contact-ia #message-creation.ko{color:#e14a5c;}";
document.head.appendChild(style);
panneau = document.createElement("div");
panneau.id = "panneau-contact-ia";
panneau.innerHTML =
'<div class="contenu-scrollable">' +
'<div class="entete-prompt">' +
'<div id="ligne-prompt-1"><span id="prompt-texte-1"></span><span id="curseur-clignotant">_</span></div>' +
'<div id="ligne-prompt-2" hidden><span id="prompt-texte-2"></span></div>' +
'</div>' +
'<div class="lignes" id="zone-lignes"></div>' +
'<div class="resultat" id="resultat"></div>' +
'</div>';
document.body.appendChild(panneau);
lignePrompt1 = panneau.querySelector("#ligne-prompt-1");
promptTexte1 = panneau.querySelector("#prompt-texte-1");
curseurClignotant = panneau.querySelector("#curseur-clignotant");
lignePrompt2 = panneau.querySelector("#ligne-prompt-2");
promptTexte2 = panneau.querySelector("#prompt-texte-2");
zoneLignes = panneau.querySelector("#zone-lignes");
zoneResultat = panneau.querySelector("#resultat");
// -------- Champs "bruts" : label + valeur, construits une fois --------
ligneChamp(zoneResultat, "Nom", "CNT_Nom");
ligneChamp(zoneResultat, "Prénom", "CNT_Prenom");
selectStatut = document.createElement("select");
selectStatut.dataset.champTag = "statut";
(function(){
const ligne = document.createElement("div");
ligne.className = "ligne-champ";
const lab = document.createElement("span");
lab.className = "label-champ";
lab.textContent = "Statut :";
ligne.appendChild(lab);
ligne.appendChild(selectStatut);
zoneResultat.appendChild(ligne);
})();
selectProfession = document.createElement("select");
(function(){
const ligne = document.createElement("div");
ligne.className = "ligne-champ";
const lab = document.createElement("span");
lab.className = "label-champ";
lab.textContent = "Profession :";
ligne.appendChild(lab);
ligne.appendChild(selectProfession);
zoneResultat.appendChild(ligne);
})();
champNouvelleProfession = ligneChamp(zoneResultat, "Nlle profession", "__profession_nouvelle_affichage__");
champNouvelleProfession.removeAttribute("data-champ"); // pas un champ contact réel, juste un input libre
blocNouvelleProfession = champNouvelleProfession.closest(".ligne-champ");
blocNouvelleProfession.style.display = "none";
ligneChamp(zoneResultat, "Société", "CNT_Société");
ligneChamp(zoneResultat, "Service", "CNT_Service");
ligneChamp(zoneResultat, "Fonction", "CNT_Fonction");
ligneChamp(zoneResultat, "SIRET", "CNT_SIRET");
ligneChamp(zoneResultat, "Adresse 1", "CNT_AdrLig1");
ligneChamp(zoneResultat, "Adresse 2", "CNT_AdrLig2");
ligneChamp(zoneResultat, "Adresse 3", "CNT_AdrLig3");
ligneChamp(zoneResultat, "CP", "CNT_AdrCP");
ligneChamp(zoneResultat, "Ville", "CNT_AdrVille");
ligneChamp(zoneResultat, "Tél", "CNT_Tel");
ligneChamp(zoneResultat, "Portable", "CNT_Portable");
ligneChamp(zoneResultat, "Mail", "CNT_Mail");
ligneChamp(zoneResultat, "Notes", "CNT_Notes", true);
selectAssistant = document.createElement("select");
(function(){
const ligne = document.createElement("div");
ligne.className = "ligne-champ";
const lab = document.createElement("span");
lab.className = "label-champ";
lab.textContent = "Assistant :";
ligne.appendChild(lab);
ligne.appendChild(selectAssistant);
zoneResultat.appendChild(ligne);
})();
// Trois zones de saisie libre pour les actions demandées, plutôt que la
// liste dynamique "+ Ajouter une action" (select existant/nouveau par
// ligne) — demande de Jean-Marc (4 septembre 2026, encore le même soir) :
// la procédure précédente était trop complexe pour ce qui reste un essai
// rapide à la voix, et l'objectif de cette phase de test est justement
// de repérer les actions non encore couvertes par ASP_Actions — un texte
// libre capture ça sans qu'il faille d'abord chercher une correspondance
// dans une liste. Rien n'est perdu pour l'analyse : PCT_Extraction garde
// la sortie brute complète du LLM (avec sa tentative de rapprochement à
// ASP_Actions), seule PCT_Valide simplifie à ces 3 champs — voir
// remplirActions/collecterActionsValidees.
champsActions = [1, 2, 3].map(function(n){
const champ = ligneChamp(zoneResultat, "Action " + n, "__action_" + n + "_affichage__");
champ.removeAttribute("data-champ"); // pas un champ contact réel, juste un input libre
return champ;
});
boutonValider = document.createElement("button");
boutonValider.type = "button";
boutonValider.id = "bouton-valider";
boutonValider.textContent = "Valider";
zoneResultat.appendChild(boutonValider);
messageCreation = document.createElement("div");
messageCreation.id = "message-creation";
zoneResultat.appendChild(messageCreation);
brancherEvenements();
chargerPrenomPrompt();
}
// Prénom réel affiché dans le prompt façon SSH ("user@<prénom> : "), lu
// depuis preferences.php (déjà utilisé ailleurs sur cet écran pour le
// chevalet) plutôt qu'écrit en dur — repli silencieux sur "aspic" si
// indisponible, même logique que le reste d'ecran_fond.html. La valeur est
// gardée en mémoire (prenomUtilisateur) et réutilisée par
// rafraichirTextePrompt() à chaque étape de la séquence d'ouverture, plutôt
// que d'écrire le texte du prompt directement ici (le fetch peut répondre
// après que la séquence a déjà commencé).
let prenomUtilisateur = "aspic";
function chargerPrenomPrompt(){
fetch("preferences.php").then(function(r){ return r.json(); }).then(function(prefs){
prenomUtilisateur = (prefs.USR_Prenom || "aspic").toLowerCase();
rafraichirTextePrompt();
}).catch(function(){ /* repli silencieux, garde "user@aspic :" */ });
}
// -------- Séquence d'ouverture façon terminal (demande de Jean-Marc,
// 4 septembre 2026 soir) --------
// Phase "attente" : "user@<prénom> : " + curseur "_" clignotant.
// Phase "pret" (après DUREE_ATTENTE_PROMPT_MS) : "user@<prénom> : micro
// ouvert" puis, sur une deuxième ligne, "user@<prénom> : parlez !".
const DUREE_ATTENTE_PROMPT_MS = 1500;
let phasePrompt = "attente";
let minuteurSequencePrompt = null;
function rafraichirTextePrompt(){
const prefixe = "user@" + prenomUtilisateur + " : ";
if(phasePrompt === "pret"){
promptTexte1.textContent = prefixe + "micro ouvert";
curseurClignotant.hidden = true;
promptTexte2.textContent = prefixe + "parlez !";
lignePrompt2.hidden = false;
} else {
promptTexte1.textContent = prefixe;
curseurClignotant.hidden = false;
lignePrompt2.hidden = true;
}
}
function demarrerSequencePrompt(){
if(minuteurSequencePrompt) clearTimeout(minuteurSequencePrompt);
phasePrompt = "attente";
rafraichirTextePrompt();
minuteurSequencePrompt = setTimeout(function(){
phasePrompt = "pret";
rafraichirTextePrompt();
}, DUREE_ATTENTE_PROMPT_MS);
}
// -------- Hauteur du panneau bornée par le pied réel du micro --------
// Lit la position/taille effectives de #micro-desk (posées par
// positionner() dans ecran_fond.html, recalculées à chaque redimensionnement
// de la fenêtre) plutôt que de dupliquer ici la géométrie de l'image de
// fond — plus simple et jamais désynchronisé. MARGE_SOUS_PIED_MICRO_PX
// laisse un petit espace pour ne pas coller pile au pied du micro.
//
// "height" fixe et non plus "max-height" (demande de Jean-Marc, 4 septembre
// 2026, encore le même soir) : le panneau prend tout de suite sa taille
// maximale à l'ouverture, plutôt que de grandir au fur et à mesure que le
// contenu (dictée, puis analyse) se remplit — l'ascenseur de
// .contenu-scrollable s'affiche ensuite ou pas selon que ce contenu dépasse
// ou non, mais la fenêtre elle-même ne bouge plus.
const MARGE_SOUS_PIED_MICRO_PX = 16;
const HAUTEUR_MIN_PANNEAU_PX = 200;
function ajusterHauteurPanneau(){
if(!panneau || !panneau.classList.contains("visible")) return;
const piedMicro = boutonMicro.getBoundingClientRect().bottom;
const hautPanneau = panneau.getBoundingClientRect().top;
const hauteurDisponible = piedMicro - hautPanneau - MARGE_SOUS_PIED_MICRO_PX;
panneau.style.height = Math.max(HAUTEUR_MIN_PANNEAU_PX, hauteurDisponible) + "px";
}
window.addEventListener("resize", ajusterHauteurPanneau);
// Échap referme le panneau sans rien enregistrer (demande de Jean-Marc,
// "tant pis si on perd des data") — uniquement quand le panneau est
// effectivement ouvert, pour ne jamais interférer avec un usage normal
// d'Échap ailleurs sur l'écran de fond.
document.addEventListener("keydown", function(evenement){
if(evenement.key !== "Escape" && evenement.key !== "Esc") return;
if(!panneau || !panneau.classList.contains("visible")) return;
abandonnerPanneau();
});
function ouvrirPanneau(){
construirePanneau();
panneau.classList.add("visible");
ajusterHauteurPanneau();
}
function afficherErreur(texte){
const div = document.createElement("div");
div.className = "erreur";
div.textContent = texte;
zoneLignes.appendChild(div);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function afficherEnCours(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
ligneEnCours.className = "en-cours";
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.textContent = texte;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function figerLigne(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.className = "";
ligneEnCours.textContent = texte;
ligneEnCours = null;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
texteComplet = (texteComplet + " " + texte).trim();
}
// Remet le panneau à zéro (dictée brute, analyse, message) — utilisé par
// abandonnerPanneau() et après un "Valider" réussi. Ne ferme pas le
// panneau lui-même (voir brancherEvenements) : les deux appelants
// décident séparément s'ils le referment.
function reinitialiserPanneau(){
texteComplet = "";
ligneEnCours = null;
zoneLignes.innerHTML = "";
viderFormulaire();
zoneResultat.classList.remove("visible");
messageCreation.textContent = "";
messageCreation.className = "";
}
// Coupe le micro s'il tourne, remet le panneau à zéro et le referme sans
// rien enregistrer — seule la touche Échap y fait appel désormais (voir
// plus bas ; l'ancien bouton "Abandonner" faisait double emploi et a été
// retiré, demande de Jean-Marc : "tant pis si on perd des data").
function abandonnerPanneau(){
if(ecouteVoulue){ ecouteVoulue = false; reconnaissance.stop(); }
reinitialiserPanneau();
panneau.classList.remove("visible");
}
function viderFormulaire(){
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){ el.value = ""; el.classList.remove("interprete"); });
selectStatut.innerHTML = '<option value="">—</option>';
selectProfession.innerHTML = '<option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option>';
blocNouvelleProfession.style.display = "none";
champNouvelleProfession.value = "";
selectAssistant.innerHTML = '<option value="">—</option>';
champsActions.forEach(function(champ){ champ.value = ""; champ.classList.remove("interprete"); });
}
function remplirChampsTexte(champs){
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){
const nomChamp = el.dataset.champ;
if(nomChamp === "CNT_Statut") return;
const valeur = champs[nomChamp];
if(valeur){ el.value = valeur; el.classList.add("interprete"); }
});
}
function remplirStatuts(statuts, statutChoisi){
(statuts || []).forEach(function(s){
const option = document.createElement("option");
option.value = s.STA_Code;
option.textContent = s.STA_Libellé;
selectStatut.appendChild(option);
});
if(statutChoisi){ selectStatut.value = statutChoisi; selectStatut.classList.add("interprete"); }
}
function remplirProfessions(professions, idExistant, libelleNouveau){
(professions || []).forEach(function(p){
const option = document.createElement("option");
option.value = String(p.PRF_Incr);
option.textContent = p.PRF_Libelle;
selectProfession.insertBefore(option, selectProfession.lastElementChild);
});
if(idExistant){
selectProfession.value = String(idExistant);
selectProfession.classList.add("interprete");
} else if(libelleNouveau){
selectProfession.value = "__nouvelle__";
selectProfession.classList.add("interprete");
blocNouvelleProfession.style.display = "flex";
champNouvelleProfession.value = libelleNouveau;
}
}
// Pas de pré-sélection d'un assistant par défaut ici quand idChoisi est
// vide (précision de Jean-Marc, 4 septembre 2026 soir) : ce panneau reste
// un simple reflet honnête de ce qui a été dit et validé — attribuer un
// assistant par défaut à une dictée où personne n'est nommé est une
// décision métier qui appartient au traitement n8n déclenché en aval, au
// moment de transformer une proposition validée (ASP_PropositionsContact)
// en post-it réel, pas à cet écran.
//
// Si la liste "agents" arrive vide, une option le dit explicitement
// ("aucun assistant en base") plutôt que de laisser un menu qui a l'air
// simplement vide sans qu'on sache si c'est un manque de données côté
// ASP_Agents ou autre chose — remonté par Jean-Marc le 4 septembre 2026
// (encore le même soir) : le menu n'affichait rien à choisir sur son essai,
// à vérifier d'abord côté données (ASP_Agents peuplée sur la base utilisée
// pour ce test ?) avant de chercher plus loin, la requête étant strictement
// identique à celle d'agents.php, déjà en service ailleurs sur cet écran.
function remplirAssistant(agents, idChoisi){
(agents || []).forEach(function(a){
const option = document.createElement("option");
option.value = String(a.AGN_Incr);
option.textContent = a.AGN_Nom + (a.AGN_Fonction ? " (" + a.AGN_Fonction + ")" : "");
selectAssistant.appendChild(option);
});
if(!agents || !agents.length){
const optionVide = document.createElement("option");
optionVide.value = "";
optionVide.textContent = "(aucun assistant en base)";
optionVide.disabled = true;
selectAssistant.appendChild(optionVide);
}
if(idChoisi){ selectAssistant.value = String(idChoisi); selectAssistant.classList.add("interprete"); }
}
// Trois zones de texte libre plutôt qu'une liste dynamique existant/nouveau
// par ligne (voir la construction du DOM plus haut, même justification).
// Pré-remplit les 3 premières actions extraites (libellé d'une action
// existante résolu via actionsDispo, ou le libellé libre proposé par le
// LLM) ; au-delà de 3, les actions supplémentaires ne sont plus éditables
// ici mais restent intactes dans PCT_Extraction (rien n'est perdu pour
// l'analyse, seule la validation se limite à 3 — cohérent avec l'objectif
// de cette phase : un essai vocal rapide, pas un formulaire exhaustif).
// Libellé fixe utilisé par le prompt (contact_ia_interprete.php) pour
// l'action "créer/ajouter le contact" quand elle est demandée — le prompt
// demande déjà au LLM de la placer en premier dans le tableau, ce filet
// la remonte en tête côté client si jamais ce n'était pas le cas (demande
// de Jean-Marc, 4 septembre 2026 soir : cette action doit apparaître en
// premier dans la liste, il teste justement la création de contacts).
const LIBELLE_ACTION_CREATION = "Ajouter le contact";
function remplirActions(actionsDispo, actionsExtraites){
const libellesParCode = {};
(actionsDispo || []).forEach(function(a){ libellesParCode[a.ACT_Code] = a.ACT_Libelle; });
const textes = (actionsExtraites || [])
.map(function(a){
return a.action_existante_code
? (libellesParCode[a.action_existante_code] || a.action_existante_code)
: (a.action_nouvelle_libelle || "");
})
.filter(function(t){ return t; })
.sort(function(a, b){
const aCreation = a === LIBELLE_ACTION_CREATION;
const bCreation = b === LIBELLE_ACTION_CREATION;
return aCreation === bCreation ? 0 : (aCreation ? -1 : 1);
});
champsActions.forEach(function(champ, i){
if(textes[i]){ champ.value = textes[i]; champ.classList.add("interprete"); }
});
}
function collecterChamps(){
const champs = {};
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){
champs[el.dataset.champ] = el.value.trim();
});
return champs;
}
function collecterProfessionValidee(){
if(selectProfession.value === "__nouvelle__"){
return {profession_existante_id: null, profession_nouvelle_libelle: champNouvelleProfession.value.trim() || null};
}
if(selectProfession.value){
return {profession_existante_id: parseInt(selectProfession.value, 10), profession_nouvelle_libelle: null};
}
return {profession_existante_id: null, profession_nouvelle_libelle: null};
}
// Renvoie un simple tableau de textes (plus les objets
// {action_existante_code, action_nouvelle_libelle} d'avant, devenus sans
// objet avec la saisie libre) — voir remarque sur PCT_Valide dans
// contact_ia_valide.php au sujet de ce changement de forme.
function collecterActionsValidees(){
return champsActions
.map(function(champ){ return champ.value.trim(); })
.filter(function(v){ return v; });
}
// Appelée automatiquement quand le micro s'arrête proprement (voir
// l'événement "end" de la reconnaissance, plus bas) — plus de bouton
// "Interpréter" à cliquer (demande de Jean-Marc, 4 septembre 2026 soir).
// Le statut "Interprétation en cours…" est affiché dans la même zone que
// la dictée brute et les erreurs, comme une ligne de terminal parmi
// d'autres, puis retiré une fois la réponse arrivée (succès ou échec).
function lancerInterpretation(){
if(!texteComplet) return;
const ligneStatut = document.createElement("div");
ligneStatut.className = "en-cours";
ligneStatut.textContent = "Interprétation en cours…";
zoneLignes.appendChild(ligneStatut);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
fetch("contact_ia_interprete.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texte: texteComplet})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(corps){
ligneStatut.remove();
derniereExtraction = corps.extraction;
viderFormulaire();
remplirChampsTexte(corps.extraction.champs);
remplirStatuts(corps.statuts, corps.extraction.champs.CNT_Statut);
remplirProfessions(corps.professions, corps.extraction.profession_existante_id, corps.extraction.profession_nouvelle_libelle);
remplirAssistant(corps.agents, corps.extraction.assistant_id);
remplirActions(corps.actionsDisponibles, corps.extraction.actions);
zoneResultat.classList.add("visible");
zoneResultat.scrollIntoView({behavior:"smooth", block:"start"});
}).catch(function(erreur){
ligneStatut.remove();
afficherErreur("Échec de l'interprétation : " + erreur.message);
});
}
function brancherEvenements(){
selectProfession.addEventListener("change", function(){
blocNouvelleProfession.style.display = (selectProfession.value === "__nouvelle__") ? "flex" : "none";
});
boutonValider.addEventListener("click", function(){
const champs = collecterChamps();
if(!champs.CNT_Nom){
messageCreation.className = "ko";
messageCreation.textContent = "Le nom est obligatoire.";
return;
}
boutonValider.disabled = true;
boutonValider.textContent = "Enregistrement en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
const valide = Object.assign(
{champs: champs},
collecterProfessionValidee(),
{
assistant_id: selectAssistant.value ? parseInt(selectAssistant.value, 10) : null,
actions: collecterActionsValidees()
}
);
fetch("contact_ia_valide.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texteBrut: texteComplet, extraction: derniereExtraction, valide: valide})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(){
messageCreation.className = "ok";
messageCreation.textContent = "Essai enregistré (aucun contact réel créé) — merci !";
// Le panneau NE se referme PLUS après Valider (demande de Jean-Marc,
// 4 septembre 2026 soir : il enchaîne plusieurs dictées à la suite,
// sortir de la fenêtre à chaque fois n'était pas pratique). Il reste
// ouvert, remis à zéro et prêt pour la dictée suivante — Échap reste
// le seul moyen de le refermer, quand il n'y a plus rien à saisir.
//
// Le micro se rouvre aussi automatiquement (demande de Jean-Marc,
// encore le même soir : "on revient en écran vierge mais il ne se
// passe rien, je pense que le micro est fermé" — exact, il l'était).
// Sans ça, revenir à un écran vierge sans micro actif ne servait à
// rien pour enchaîner. Le garde-fou !ecouteVoulue évite de rappeler
// reconnaissance.start() si Jean-Marc a déjà recliqué le micro
// pendant les 1,8s d'affichage de la confirmation (start() sur une
// reconnaissance déjà démarrée lève une erreur).
setTimeout(function(){
reinitialiserPanneau();
boutonValider.disabled = false;
boutonValider.textContent = "Valider";
if(!ecouteVoulue) demarrerEcoute();
}, 1800);
}).catch(function(erreur){
messageCreation.className = "ko";
messageCreation.textContent = "Échec de l'enregistrement : " + erreur.message;
boutonValider.disabled = false;
boutonValider.textContent = "Valider";
});
});
}
// -------- Micro (#micro-desk) --------
let dernierDemarrageHorodatage = 0;
let cyclesRapidesConsecutifs = 0;
const SEUIL_CYCLE_RAPIDE_MS = 800;
const MAX_CYCLES_RAPIDES = 5;
function passerEnEcoute(){
boutonMicro.classList.add("ecoute");
boutonMicro.title = "Le micro est ouvert (interprétation IA de contact — outil de mise au point)";
ouvrirPanneau();
demarrerSequencePrompt();
reinitialiserMinuteurInactivite();
}
function revenirAuRepos(){
ecouteVoulue = false;
arreterMinuteurInactivite();
arreterMinuteurSilence();
boutonMicro.classList.remove("ecoute");
boutonMicro.title = "Le micro est inactif";
// Le panneau reste visible volontairement (voir en-tête) : seule
// l'écoute s'arrête, pour laisser Jean-Marc relire/corriger/valider.
}
// Factorisé pour être appelable aussi bien depuis le clic sur le micro que
// depuis la reprise automatique après un "Valider" réussi (voir
// brancherEvenements) — même logique, même gestion d'erreur.
function demarrerEcoute(){
ecouteVoulue = true;
erreurBloquante = false;
try{
reconnaissance.start();
}catch(erreur){
ecouteVoulue = false;
ouvrirPanneau();
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
}
}
boutonMicro.addEventListener("click", function(){
if(ecouteVoulue){
ecouteVoulue = false;
reconnaissance.stop();
return;
}
demarrerEcoute();
});
reconnaissance.addEventListener("start", function(){
dernierDemarrageHorodatage = Date.now();
passerEnEcoute();
});
reconnaissance.addEventListener("end", function(){
if(ecouteVoulue && !erreurBloquante){
const ecoule = Date.now() - dernierDemarrageHorodatage;
cyclesRapidesConsecutifs = (ecoule < SEUIL_CYCLE_RAPIDE_MS) ? (cyclesRapidesConsecutifs + 1) : 0;
if(cyclesRapidesConsecutifs >= MAX_CYCLES_RAPIDES){
erreurBloquante = true;
revenirAuRepos();
afficherErreur("Le micro redémarre en boucle anormalement vite — arrêt automatique. Réessaie dans quelques secondes.");
return;
}
try{
reconnaissance.start();
}catch(erreur){
revenirAuRepos();
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
}
return;
}
// Arrêt propre (clic volontaire de Jean-Marc, ecouteVoulue déjà à false,
// pas une erreur bloquante) : c'est le signal pour lancer l'interprétation
// automatiquement, plus de bouton dédié (voir en-tête et lancerInterpretation).
const arretPropre = !erreurBloquante;
revenirAuRepos();
if(arretPropre) lancerInterpretation();
});
reconnaissance.addEventListener("result", function(evenement){
const dernier = evenement.results[evenement.results.length - 1];
const texte = dernier[0].transcript.trim();
if(!texte) return;
// Tout résultat (intermédiaire ou final) repousse l'arrêt par silence :
// Jean-Marc parle encore, ou vient de parler. Voir DUREE_SILENCE_ARRET_MS.
reinitialiserMinuteurSilence();
if(dernier.isFinal){
figerLigne(texte);
reinitialiserMinuteurInactivite();
} else {
afficherEnCours(texte);
}
});
reconnaissance.addEventListener("error", function(evenement){
const messagesBloquants = {
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"audio-capture": "Aucun micro détecté."
};
if(messagesBloquants[evenement.error]){
erreurBloquante = true;
ouvrirPanneau();
afficherErreur(messagesBloquants[evenement.error]);
}
});
})();
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — Test IA création de contact</title>
<link rel="icon" href="mevelia-icone.png">
<style>
/* contact_ia_test.html — 4 septembre 2026
Outil de mise au point interne (PAS un écran utilisateur), voir
contact_ia_interprete.php pour le détail du choix d'architecture.
Page autonome, volontairement séparée d'ecran_fond.html/micro_test.js :
la collecte brute de dictées (banque de corpus, table ASP_DicteesTest)
continue de tourner sans interférence — cette page est un banc d'essai
à part pour la première étape d'interprétation (LLM à sortie
structurée), qu'on peut abandonner ou retravailler sans rien défaire
ailleurs.
Look "terminal" repris de la fenêtre de micro_test.js (fond noir, police
monospace) — c'est ce rendu qui a plu à Jean-Marc : on est "dans le cœur
de la bête", pas dans un écran utilisateur poli, ce qui prépare
psychologiquement à un outil moins accompagné qu'un vrai formulaire.
Correction du 4 septembre 2026 (soir), Jean-Marc : « Valider » ne crée
surtout PAS de contact réel. Ça enregistre seulement l'essai (texte brut
+ interprétation du LLM + correction éventuelle) dans une table à part
(ASP_PropositionsContact, via contact_ia_valide.php), en vue d'une
évolution où chaque instruction vocale validée donnera naissance à un
post-it pour un assistant — pas encore câblé, ASP_ToDoList ne sachant
pas encore attribuer une tâche à un assistant (voir "reste à faire" du
journal de développement). */
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;background:#111;color:#ddd;
font-family:"Segoe UI","Arial",sans-serif;}
#page{max-width:720px;margin:0 auto;padding:24px 16px 60px;}
h1{font-size:16px;color:#888;font-weight:normal;margin:0 0 4px;}
.sous-titre{font-size:12px;color:#666;margin:0 0 20px;}
/* -------- Terminal (dictée brute) -------- */
#terminal{background:#000;border:1px solid #333;border-radius:6px;
font-family:"Courier New",Consolas,monospace;overflow:hidden;
margin-bottom:18px;}
#terminal .entete{color:#888;font-size:11px;padding:6px 10px;
border-bottom:1px solid #222;}
#terminal .controles{display:flex;align-items:center;gap:8px;
padding:8px 10px;border-bottom:1px solid #222;flex-wrap:wrap;}
#terminal .controles button{font:inherit;font-size:13px;
background:#1a1a1a;color:#ccc;border:1px solid #444;border-radius:4px;
padding:6px 14px;cursor:pointer;}
#terminal .controles button.actif{background:#2a4d2a;color:#bdf7bd;
border-color:#3a6d3a;}
#terminal .controles button:disabled{opacity:.4;cursor:default;}
#terminal .controles .etat{margin-left:auto;color:#777;font-size:11px;}
#terminal .lignes{min-height:80px;max-height:260px;overflow-y:auto;
padding:10px;color:#fff;font-size:14px;line-height:1.6;
white-space:pre-wrap;}
#terminal .lignes .en-cours{color:#8a8a8a;font-style:italic;}
#terminal .lignes .erreur{color:#e14a5c;}
/* -------- Formulaire de champs structurés -------- */
#resultat{display:none;}
#resultat.visible{display:block;}
#resultat .bloc{background:#1a1a1a;border:1px solid #333;border-radius:6px;
padding:14px 16px;margin-bottom:14px;}
#resultat .bloc h2{font-size:12px;color:#888;font-weight:normal;
text-transform:uppercase;letter-spacing:.05em;margin:0 0 12px;}
.champ{margin-bottom:10px;}
.champ label{display:block;font-size:11px;color:#999;margin-bottom:3px;}
.champ input,.champ select,.champ textarea{width:100%;font:inherit;
font-size:13px;background:#0d0d0d;color:#eee;border:1px solid #444;
border-radius:4px;padding:6px 8px;}
.champ input.interprete,.champ select.interprete{border-color:#3a6d3a;}
.champ textarea{min-height:60px;resize:vertical;font-family:inherit;}
.grille2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
#bouton-creer{font:inherit;font-size:14px;background:#2a4d2a;color:#bdf7bd;
border:1px solid #3a6d3a;border-radius:5px;padding:10px 20px;
cursor:pointer;}
#bouton-creer:disabled{opacity:.4;cursor:default;}
#message-creation{margin-top:10px;font-size:13px;}
#message-creation.ok{color:#7be07b;}
#message-creation.ko{color:#e14a5c;}
</style>
</head>
<body>
<div id="page">
<h1>Création de contact — essai d'interprétation IA</h1>
<p class="sous-titre">Outil de mise au point interne, pas un écran utilisateur. Dicte librement comme si tu créais un contact, puis clique sur « Interpréter ».</p>
<div id="terminal">
<div class="entete">Dictée brute</div>
<div class="controles">
<button type="button" id="bouton-micro">🎤 Micro</button>
<button type="button" id="bouton-interpreter" disabled>Interpréter →</button>
<button type="button" id="bouton-reinitialiser">Réinitialiser</button>
<span class="etat" id="etat-micro"></span>
</div>
<div class="lignes" id="zone-lignes"></div>
</div>
<div id="resultat">
<div class="bloc">
<h2>Identité</h2>
<div class="grille2">
<div class="champ"><label for="f-nom">Nom</label><input id="f-nom" data-champ="CNT_Nom"></div>
<div class="champ"><label for="f-prenom">Prénom</label><input id="f-prenom" data-champ="CNT_Prenom"></div>
</div>
<div class="grille2">
<div class="champ"><label for="f-statut">Statut</label>
<select id="f-statut" data-champ="CNT_Statut"><option value="">—</option></select>
</div>
<div class="champ"><label for="f-profession">Profession</label>
<select id="f-profession"><option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option></select>
</div>
</div>
<div class="champ" id="bloc-nouvelle-profession" style="display:none;">
<label for="f-profession-nouvelle">Libellé de la nouvelle profession</label>
<input id="f-profession-nouvelle">
</div>
</div>
<div class="bloc">
<h2>Société</h2>
<div class="grille2">
<div class="champ"><label for="f-societe">Société</label><input id="f-societe" data-champ="CNT_Société"></div>
<div class="champ"><label for="f-service">Service</label><input id="f-service" data-champ="CNT_Service"></div>
</div>
<div class="champ"><label for="f-fonction">Fonction</label><input id="f-fonction" data-champ="CNT_Fonction"></div>
<div class="champ"><label for="f-siret">SIRET</label><input id="f-siret" data-champ="CNT_SIRET"></div>
</div>
<div class="bloc">
<h2>Adresse</h2>
<div class="champ"><label for="f-adr1">Ligne 1</label><input id="f-adr1" data-champ="CNT_AdrLig1"></div>
<div class="champ"><label for="f-adr2">Ligne 2</label><input id="f-adr2" data-champ="CNT_AdrLig2"></div>
<div class="champ"><label for="f-adr3">Ligne 3</label><input id="f-adr3" data-champ="CNT_AdrLig3"></div>
<div class="grille2">
<div class="champ"><label for="f-cp">Code postal</label><input id="f-cp" data-champ="CNT_AdrCP"></div>
<div class="champ"><label for="f-ville">Ville</label><input id="f-ville" data-champ="CNT_AdrVille"></div>
</div>
</div>
<div class="bloc">
<h2>Coordonnées</h2>
<div class="grille2">
<div class="champ"><label for="f-tel">Téléphone</label><input id="f-tel" data-champ="CNT_Tel"></div>
<div class="champ"><label for="f-portable">Portable</label><input id="f-portable" data-champ="CNT_Portable"></div>
</div>
<div class="champ"><label for="f-mail">Mail</label><input id="f-mail" data-champ="CNT_Mail"></div>
</div>
<div class="bloc">
<h2>Notes (contexte non rattaché à un champ précis)</h2>
<div class="champ"><textarea id="f-notes" data-champ="CNT_Notes"></textarea></div>
</div>
<button type="button" id="bouton-creer">Valider</button>
<p class="sous-titre">« Valider » n'enregistre PAS de contact réel — seulement cet essai (brut + interprétation + ta correction) pour analyse.</p>
<div id="message-creation"></div>
</div>
</div>
<script src="contact_ia_test.js"></script>
</body>
</html>
+364
View File
@@ -0,0 +1,364 @@
/**
* ASPIC — contact_ia_test.js
*
* Logique de contact_ia_test.html — outil de mise au point interne
* (4 septembre 2026), voir l'en-tête de contact_ia_interprete.php pour le
* contexte et les choix d'architecture.
*
* Reprend, pour la partie micro/reconnaissance vocale, le squelette déjà
* éprouvé de micro_test.js (redémarrage silencieux du moteur, coupe-circuit
* anti-boucle, coupure après inactivité) — volontairement dupliqué plutôt
* que factorisé : ce fichier doit pouvoir être abandonné/retravaillé sans
* aucun risque de toucher à micro_test.js, qui continue d'alimenter la
* banque de dictées brutes en production. Contrairement à micro_test.js,
* pas de "mode adresse" ici : si un champ dicté (email, orthographe d'un nom
* rare...) est mal capté, la correction se fait directement dans le champ
* du formulaire affiché après interprétation — c'est précisément
* l'échappatoire "passer en saisie" actée avec Jean-Marc le 4 septembre
* (réflexions stratégiques, section 12) : chaque champ du formulaire EST
* cette échappatoire, pas besoin d'un mécanisme dédié en plus.
*/
(function(){
"use strict";
const boutonMicro = document.getElementById("bouton-micro");
const boutonInterpreter = document.getElementById("bouton-interpreter");
const boutonReinitialiser = document.getElementById("bouton-reinitialiser");
const etatMicro = document.getElementById("etat-micro");
const zoneLignes = document.getElementById("zone-lignes");
const zoneResultat = document.getElementById("resultat");
const boutonCreer = document.getElementById("bouton-creer");
const messageCreation = document.getElementById("message-creation");
const selectStatut = document.getElementById("f-statut");
const selectProfession = document.getElementById("f-profession");
const blocNouvelleProfession = document.getElementById("bloc-nouvelle-profession");
const champNouvelleProfession = document.getElementById("f-profession-nouvelle");
// -------- Reconnaissance vocale (squelette repris de micro_test.js) --------
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
let reconnaissance = null;
let ecouteVoulue = false;
let erreurBloquante = false;
let ligneEnCours = null;
let texteComplet = ""; // buffer de toute la dictée depuis le dernier "Réinitialiser"
let derniereExtraction = null; // dernière réponse de contact_ia_interprete.php, gardée pour comparaison avec ce que Jean-Marc valide ensuite
const DUREE_INACTIVITE_MS = 10 * 60 * 1000; // 10 minutes, même prudence que micro_test.js
let minuteurInactivite = null;
function reinitialiserMinuteurInactivite(){
if(minuteurInactivite) clearTimeout(minuteurInactivite);
minuteurInactivite = setTimeout(function(){
if(!ecouteVoulue) return;
ecouteVoulue = false;
reconnaissance.stop();
}, DUREE_INACTIVITE_MS);
}
function arreterMinuteurInactivite(){
if(minuteurInactivite){ clearTimeout(minuteurInactivite); minuteurInactivite = null; }
}
function afficherErreur(texte){
const div = document.createElement("div");
div.className = "erreur";
div.textContent = texte;
zoneLignes.appendChild(div);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function afficherEnCours(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
ligneEnCours.className = "en-cours";
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.textContent = texte;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function figerLigne(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.className = "";
ligneEnCours.textContent = texte;
ligneEnCours = null;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
texteComplet = (texteComplet + " " + texte).trim();
boutonInterpreter.disabled = (texteComplet === "");
}
if(!ReconnaissanceVocale){
boutonMicro.disabled = true;
boutonMicro.title = "Reconnaissance vocale non disponible sur ce navigateur";
} else {
reconnaissance = new ReconnaissanceVocale();
reconnaissance.lang = "fr-FR";
reconnaissance.continuous = true;
reconnaissance.interimResults = true;
reconnaissance.maxAlternatives = 1;
let dernierDemarrageHorodatage = 0;
let cyclesRapidesConsecutifs = 0;
const SEUIL_CYCLE_RAPIDE_MS = 800;
const MAX_CYCLES_RAPIDES = 5;
function passerEnEcoute(){
boutonMicro.classList.add("actif");
boutonMicro.textContent = "🎤 Écoute en cours…";
etatMicro.textContent = "micro ouvert";
reinitialiserMinuteurInactivite();
}
function revenirAuRepos(){
ecouteVoulue = false;
arreterMinuteurInactivite();
boutonMicro.classList.remove("actif");
boutonMicro.textContent = "🎤 Micro";
etatMicro.textContent = "";
}
boutonMicro.addEventListener("click", function(){
if(ecouteVoulue){
ecouteVoulue = false;
reconnaissance.stop();
return;
}
ecouteVoulue = true;
erreurBloquante = false;
try{
reconnaissance.start();
}catch(erreur){
ecouteVoulue = false;
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
}
});
reconnaissance.addEventListener("start", function(){
dernierDemarrageHorodatage = Date.now();
passerEnEcoute();
});
reconnaissance.addEventListener("end", function(){
if(ecouteVoulue && !erreurBloquante){
const ecoule = Date.now() - dernierDemarrageHorodatage;
cyclesRapidesConsecutifs = (ecoule < SEUIL_CYCLE_RAPIDE_MS) ? (cyclesRapidesConsecutifs + 1) : 0;
if(cyclesRapidesConsecutifs >= MAX_CYCLES_RAPIDES){
erreurBloquante = true;
revenirAuRepos();
afficherErreur("Le micro redémarre en boucle anormalement vite — arrêt automatique. Réessaie dans quelques secondes.");
return;
}
try{
reconnaissance.start();
}catch(erreur){
revenirAuRepos();
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
}
return;
}
revenirAuRepos();
});
reconnaissance.addEventListener("result", function(evenement){
const dernier = evenement.results[evenement.results.length - 1];
const texte = dernier[0].transcript.trim();
if(!texte) return;
if(dernier.isFinal){
figerLigne(texte);
reinitialiserMinuteurInactivite();
} else {
afficherEnCours(texte);
}
});
reconnaissance.addEventListener("error", function(evenement){
const messagesBloquants = {
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"audio-capture": "Aucun micro détecté."
};
if(messagesBloquants[evenement.error]){
erreurBloquante = true;
afficherErreur(messagesBloquants[evenement.error]);
}
});
}
// -------- Réinitialiser --------
boutonReinitialiser.addEventListener("click", function(){
if(ecouteVoulue && reconnaissance){ ecouteVoulue = false; reconnaissance.stop(); }
texteComplet = "";
ligneEnCours = null;
zoneLignes.innerHTML = "";
boutonInterpreter.disabled = true;
zoneResultat.classList.remove("visible");
messageCreation.textContent = "";
messageCreation.className = "";
});
// -------- Interpréter --------
function viderFormulaire(){
document.querySelectorAll("[data-champ]").forEach(function(el){ el.value = ""; el.classList.remove("interprete"); });
selectStatut.innerHTML = '<option value="">—</option>';
selectProfession.innerHTML = '<option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option>';
blocNouvelleProfession.style.display = "none";
champNouvelleProfession.value = "";
}
function remplirChampsTexte(champs){
document.querySelectorAll("[data-champ]").forEach(function(el){
const nomChamp = el.dataset.champ;
if(nomChamp === "CNT_Statut") return; // géré à part (select)
const valeur = champs[nomChamp];
if(valeur){
el.value = valeur;
el.classList.add("interprete");
}
});
}
function remplirStatuts(statuts, statutChoisi){
statuts.forEach(function(s){
const option = document.createElement("option");
option.value = s.STA_Code;
option.textContent = s.STA_Libellé;
selectStatut.appendChild(option);
});
if(statutChoisi){
selectStatut.value = statutChoisi;
selectStatut.classList.add("interprete");
}
}
function remplirProfessions(professions, idExistant, libelleNouveau){
professions.forEach(function(p){
const option = document.createElement("option");
option.value = String(p.PRF_Incr);
option.textContent = p.PRF_Libelle;
// Insertion avant l'option "+ Nouvelle profession…", toujours en dernier.
selectProfession.insertBefore(option, selectProfession.lastElementChild);
});
if(idExistant){
selectProfession.value = String(idExistant);
selectProfession.classList.add("interprete");
} else if(libelleNouveau){
selectProfession.value = "__nouvelle__";
selectProfession.classList.add("interprete");
blocNouvelleProfession.style.display = "block";
champNouvelleProfession.value = libelleNouveau;
}
}
selectProfession.addEventListener("change", function(){
blocNouvelleProfession.style.display = (selectProfession.value === "__nouvelle__") ? "block" : "none";
});
boutonInterpreter.addEventListener("click", function(){
if(!texteComplet) return;
boutonInterpreter.disabled = true;
boutonInterpreter.textContent = "Interprétation en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
fetch("contact_ia_interprete.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texte: texteComplet})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(corps){
derniereExtraction = corps.extraction;
viderFormulaire();
remplirChampsTexte(corps.extraction.champs);
remplirStatuts(corps.statuts, corps.extraction.champs.CNT_Statut);
remplirProfessions(
corps.professions,
corps.extraction.profession_existante_id,
corps.extraction.profession_nouvelle_libelle
);
zoneResultat.classList.add("visible");
zoneResultat.scrollIntoView({behavior:"smooth", block:"start"});
}).catch(function(erreur){
afficherErreur("Échec de l'interprétation : " + erreur.message);
}).finally(function(){
boutonInterpreter.disabled = (texteComplet === "");
boutonInterpreter.textContent = "Interpréter →";
});
});
// -------- Valider (4 septembre 2026) --------
// Correction explicite de Jean-Marc : cet outil ne doit JAMAIS créer de
// contact réel (ni de profession réelle au passage) — contrairement à ce
// qu'une première version faisait en appelant contacts.php directement.
// L'objectif de cette étape est seulement d'accumuler, pour chaque essai,
// trois choses côte à côte (texte brut / ce que le LLM a compris / ce que
// Jean-Marc valide après relecture-correction) afin d'en tirer des
// conclusions sur la qualité de l'interprétation — et, plus tard, de
// servir de matière à la création d'un post-it par assistant pour chaque
// instruction vocale (vision "bureau à l'ancienne", pas encore câblée :
// ASP_ToDoList ne sait pas encore attribuer une tâche à un assistant, voir
// le "reste à faire" du journal de développement). Rien n'est donc écrit
// dans ASP_Contacts ni ASP_Professions depuis cette page — uniquement dans
// la nouvelle table ASP_PropositionsContact, via contact_ia_valide.php.
function collecterChamps(){
const champs = {};
document.querySelectorAll("[data-champ]").forEach(function(el){
champs[el.dataset.champ] = el.value.trim();
});
return champs;
}
function collecterProfessionValidee(){
if(selectProfession.value === "__nouvelle__"){
return {profession_existante_id: null, profession_nouvelle_libelle: champNouvelleProfession.value.trim() || null};
}
if(selectProfession.value){
return {profession_existante_id: parseInt(selectProfession.value, 10), profession_nouvelle_libelle: null};
}
return {profession_existante_id: null, profession_nouvelle_libelle: null};
}
boutonCreer.addEventListener("click", function(){
const champs = collecterChamps();
if(!champs.CNT_Nom){
messageCreation.className = "ko";
messageCreation.textContent = "Le nom est obligatoire.";
return;
}
boutonCreer.disabled = true;
boutonCreer.textContent = "Enregistrement en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
const valide = Object.assign({champs: champs}, collecterProfessionValidee());
fetch("contact_ia_valide.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
texteBrut: texteComplet,
extraction: derniereExtraction,
valide: valide
})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(){
messageCreation.className = "ok";
messageCreation.textContent = "Essai enregistré (aucun contact réel créé) — merci !";
}).catch(function(erreur){
messageCreation.className = "ko";
messageCreation.textContent = "Échec de l'enregistrement : " + erreur.message;
}).finally(function(){
boutonCreer.disabled = false;
boutonCreer.textContent = "Valider";
});
});
})();
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* ASPIC — contact_ia_valide.php
*
* Outil de mise au point interne (4 septembre 2026, correction explicite de
* Jean-Marc suite à un premier essai de contact_ia_test.html/.js) — PAS un
* endpoint destiné aux futurs utilisateurs.
*
* Ne crée JAMAIS de contact réel (ni de profession réelle) : la première
* version de contact_ia_test.js appelait directement contacts.php/
* professions.php depuis cet outil de test, ce que Jean-Marc a explicitement
* écarté. Ce fichier se contente d'enregistrer, côte à côte, les trois
* étapes d'un essai d'interprétation :
* - le texte brut tel que dicté (identique à l'esprit de dictees_test.php) ;
* - ce que le LLM a compris (JSON renvoyé par contact_ia_interprete.php,
* avant toute correction) ;
* - ce que Jean-Marc a réellement validé après relecture (JSON des champs du
* formulaire tels qu'affichés/corrigés au moment du clic sur "Valider").
*
* Objectif explicite de Jean-Marc : comparer les deux JSON dans la durée
* pour juger la qualité de l'interprétation, et — plus tard — s'appuyer sur
* ce qui est validé pour générer un post-it par instruction vocale, attribué
* à un assistant (vision "bureau à l'ancienne"). Cette seconde partie n'est
* PAS implémentée ici : elle suppose qu'un assistant puisse être attributaire
* d'une tâche dans ASP_ToDoList, ce qui n'existe pas encore (voir "reste à
* faire" du journal de développement) — ASP_PropositionsContact est
* volontairement une table à part, pas une écriture anticipée dans
* ASP_ToDoList qui préjugerait d'un mécanisme pas encore conçu.
*
* POST uniquement, corps JSON :
* { "texteBrut": "...", "extraction": {...} | null, "valide": {...} }
* "extraction" peut être null si l'utilisateur clique "Valider" sans être
* passé par "Interpréter" (cas limite, pas bloqué ici).
*
* ⚠️ Nécessite la création préalable de la table par Jean-Marc lui-même
* (Claude n'exécute jamais de modification de schéma — voir réflexions
* stratégiques) :
*
* CREATE TABLE ASP_PropositionsContact (
* PCT_Incr INT AUTO_INCREMENT PRIMARY KEY,
* PCT_TexteBrut TEXT NOT NULL,
* PCT_Extraction JSON NULL,
* PCT_Valide JSON NOT NULL,
* PCT_Utilisateur VARCHAR(10) NOT NULL,
* PCT_Date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
* );
*
* Session utilisateur : accès réservé aux utilisateurs identifiés, même
* principe que 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;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$texteBrut = trim((string) ($data['texteBrut'] ?? ''));
$extraction = $data['extraction'] ?? null;
$valide = $data['valide'] ?? null;
if ($texteBrut === '') {
http_response_code(400);
echo json_encode(['error' => 'Rien à enregistrer (texte brut vide)']);
exit;
}
if (!$valide) {
http_response_code(400);
echo json_encode(['error' => 'Rien à enregistrer (champs validés absents)']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
$stmt = $pdo->prepare(
'INSERT INTO ASP_PropositionsContact (PCT_TexteBrut, PCT_Extraction, PCT_Valide, PCT_Utilisateur)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$texteBrut,
$extraction !== null ? json_encode($extraction, JSON_UNESCAPED_UNICODE) : null,
json_encode($valide, JSON_UNESCAPED_UNICODE),
$_SESSION['USR_Code'],
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage() . ' — la table ASP_PropositionsContact existe-t-elle ? (voir en-tête de ce fichier)']);
exit;
}
echo json_encode(['success' => true, 'id' => (int) $pdo->lastInsertId()]);
+21 -19
View File
@@ -124,21 +124,23 @@
temporairement détourné de son usage final. Plutôt que micro.js (le temporairement détourné de son usage final. Plutôt que micro.js (le
vrai pilotage, agents nommés Viktor/Matilda/Léon/Nikita, mode vrai pilotage, agents nommés Viktor/Matilda/Léon/Nikita, mode
notation — resté inchangé et toujours utilisé tel quel sur notation — resté inchangé et toujours utilisé tel quel sur
liste_contacts.html), c'est micro_test.js qui est chargé ici, le liste_contacts.html), c'est d'abord micro_test.js qui a été chargé
temps de la phase de mise au point du pilotage vocal : outil de ici, le temps de la phase de constitution d'une banque de dictées
constitution d'une banque de dictées réelles (table réelles (table ASP_DicteesTest, thème "Gestion des Contacts").
ASP_DicteesTest, thème "Gestion des Contacts"), PAS un module
destiné aux futurs utilisateurs (voir le commentaire d'en-tête de 4 septembre 2026 (soir) : cette phase de collecte a rempli son rôle
micro_test.js). Écoute continue, aucun mot déclencheur nécessaire (décision de Jean-Marc, "l'ancien panneau ne nous sert plus à rien")
(le clic sur le micro fait déjà office de signal explicite), chaque — micro_test.js est retiré d'ici (le fichier reste en place, juste
énoncé restitué au fur et à mesure dans une fenêtre dédiée puis plus chargé) au profit de contact_ia_panel.js, qui branche sur ce
enregistré tel quel — y compris les essais ratés, jamais même bouton la première interprétation réelle d'une dictée de
supprimables depuis l'écran (décision explicite de Jean-Marc : ça contact par LLM (voir contact_ia_interprete.php). Toujours un outil
fera aussi partie de la vraie vie des futurs utilisateurs). Pour de mise au point, pas un module utilisateur final : rien n'est créé
reconnecter le vrai pilotage sur ce micro une fois cette phase en base contact/profession réels, seulement enregistré pour analyse
terminée : remettre <script src="micro.js"> à la place de (voir contact_ia_valide.php). Pour reconnecter le vrai pilotage sur
<script src="micro_test.js"> en bas de cette page, rien d'autre à ce micro une fois cette phase terminée : remettre
défaire. */ <script src="micro.js"> à la place de
<script src="contact_ia_panel.js"> en bas de cette page, rien
d'autre à défaire. */
#micro-desk{ #micro-desk{
position:fixed; position:fixed;
border:none;background:transparent;padding:0;margin:0; border:none;background:transparent;padding:0;margin:0;
@@ -160,8 +162,8 @@
} }
#micro-icone svg[hidden]{display:none;} #micro-icone svg[hidden]{display:none;}
/* Bascule repos/écoute pilotée par la classe "ecoute" que le script chargé /* Bascule repos/écoute pilotée par la classe "ecoute" que le script chargé
en bas de page ajoute sur #micro-desk (micro_test.js actuellement — voir en bas de page ajoute sur #micro-desk (contact_ia_panel.js actuellement —
commentaire plus haut sur le détournement temporaire de ce micro ; même voir commentaire plus haut sur le détournement temporaire de ce micro ; même
mécanisme que #bouton-micro.ecoute dans liste_contacts.html avec mécanisme que #bouton-micro.ecoute dans liste_contacts.html avec
micro.js) — combinateur de fratrie ~ nécessaire ici puisque micro.js) — combinateur de fratrie ~ nécessaire ici puisque
#micro-icone n'est pas un descendant du bouton. Spécificité (3 id + 1 #micro-icone n'est pas un descendant du bouton. Spécificité (3 id + 1
@@ -532,7 +534,7 @@ Créé par Mevelia Solutions">
positionner(); positionner();
// Bascule ouvert/fermé du micro du bureau : gérée par le script chargé en // Bascule ouvert/fermé du micro du bureau : gérée par le script chargé en
// fin de page (micro_test.js actuellement, voir commentaire plus haut), // fin de page (contact_ia_panel.js actuellement, voir commentaire plus haut),
// qui pilote le clic, la reconnaissance vocale et la classe "ecoute" sur // qui pilote le clic, la reconnaissance vocale et la classe "ecoute" sur
// #micro-desk — voir la règle CSS correspondante plus // #micro-desk — voir la règle CSS correspondante plus
// haut pour la bascule visuelle des deux glyphes. L'ancien toggle purement // haut pour la bascule visuelle des deux glyphes. L'ancien toggle purement
@@ -684,7 +686,7 @@ Créé par Mevelia Solutions">
// faute d'un "retour" qui ait un sens depuis l'écran d'accueil. À revoir // faute d'un "retour" qui ait un sens depuis l'écran d'accueil. À revoir
// le jour où la ToDoList aura son propre objet cliquable sur le bureau. // le jour où la ToDoList aura son propre objet cliquable sur le bureau.
</script> </script>
<script src="micro_test.js"></script> <script src="contact_ia_panel.js"></script>
</body> </body>
</html> </html>
+8 -2
View File
@@ -45,10 +45,11 @@ Pour les trois documents de ce projet (ce journal — résumé/index —, les R
- 26. ToDoList - 26. ToDoList
- 27. Pilotage vocal - 27. Pilotage vocal
- 28. Écran de fond - 28. Écran de fond
- 29. Interprétation vocale des contacts par LLM
*(Numérotation et titres repris du découpage fait par Jean-Marc le 31 août 2026, légèrement reformulés par lui à cette occasion — se référer aux fichiers eux-mêmes pour le libellé exact. Section 27 (Pilotage vocal) validée de bout en bout et son fichier définitif déposé dans `/journal` le 2 septembre 2026. Section 28 (Écran de fond) ouverte le 2 septembre 2026, toujours **en cours** — passée d'une maquette graphique exploratoire à un premier essai fonctionnel réel, puis (soirée du 2 au 3 septembre) à une pastille de comptage sur le classeur Contacts et à un premier affichage réel des post-its personnels sur le tableau de liège ; fichier de section mis à jour à déposer par Jean-Marc, voir Statut actuel.)* *(Numérotation et titres repris du découpage fait par Jean-Marc le 31 août 2026, légèrement reformulés par lui à cette occasion — se référer aux fichiers eux-mêmes pour le libellé exact. Section 27 (Pilotage vocal) validée de bout en bout et son fichier définitif déposé dans `/journal` le 2 septembre 2026. Section 28 (Écran de fond) ouverte le 2 septembre 2026, toujours **en cours** — passée d'une maquette graphique exploratoire à un premier essai fonctionnel réel, puis (soirée du 2 au 3 septembre) à une pastille de comptage sur le classeur Contacts et à un premier affichage réel des post-its personnels sur le tableau de liège. Section 29 (Interprétation vocale des contacts par LLM) ouverte et son fichier définitif déposé le 4 septembre 2026 après validation de bout en bout sur un premier corpus réel ; fichiers de section à déposer par Jean-Marc, voir Statut actuel.)*
## Statut actuel (2-3 septembre 2026) ## Statut actuel (2-4 septembre 2026)
**Écrans "Identification" et "Liste des contacts"** (avec panneau détail intégré) : aboutis depuis le 25 août 2026, testés en production. **Prospection** (formulaire externe, vérification mail, envoi de doc) : livrée et testée en conditions réelles le 26 août 2026 (section 18). **Dépôt Git** en place depuis le 27-28 août (section 20), avec circuit de livraison FileZilla → SSH VPS → `git push` (section 23) — l'écriture directe par Claude reste bloquée côté proxy Anthropic, contournement adopté sans gêne. **MIRADOR** (authentification centralisée multi-bases, identifiant = email) branché et validé à deux utilisateurs le 30 août 2026, déployé sur `aspic.mvl-tls.fr` (développement) et `crm.aspic.bzh` (test), chacun avec son propre environnement Docker (sections 21, 24, 25). **Écrans "Identification" et "Liste des contacts"** (avec panneau détail intégré) : aboutis depuis le 25 août 2026, testés en production. **Prospection** (formulaire externe, vérification mail, envoi de doc) : livrée et testée en conditions réelles le 26 août 2026 (section 18). **Dépôt Git** en place depuis le 27-28 août (section 20), avec circuit de livraison FileZilla → SSH VPS → `git push` (section 23) — l'écriture directe par Claude reste bloquée côté proxy Anthropic, contournement adopté sans gêne. **MIRADOR** (authentification centralisée multi-bases, identifiant = email) branché et validé à deux utilisateurs le 30 août 2026, déployé sur `aspic.mvl-tls.fr` (développement) et `crm.aspic.bzh` (test), chacun avec son propre environnement Docker (sections 21, 24, 25).
@@ -84,7 +85,12 @@ Version incrémentée une seule fois pour l'ensemble de cette session (pastille
**3 septembre 2026 (nuit) : dictée d'adresses mail — diagnostic et deux bugs de robustesse corrigés dans l'outil de banque de dictées (V1.011).** Détail complet en section 27 du journal ; comparatif de prix des API de reconnaissance vocale en réflexions stratégiques, section 12 (addendum du même soir). Résumé : les premiers essais réels de Jean-Marc ont montré un taux d'échec très élevé (~1/50) sur la dictée d'une adresse mail. Diagnostic à partir d'exports réels de `ASP_DicteesTest` : trois causes distinctes — vocabulaire de ponctuation dicté ("point", "tiret", "arobase") converti de façon incohérente par le moteur gratuit du navigateur, segmentation de la dictée en plusieurs lignes non fiable, et des lettres purement avalées sur des mots sans support linguistique réel (ex. "mvl-tls.fr" → "mvtls.fr", perte acoustique jamais rattrapable après coup). Ajout d'un "mode adresse" dans `micro_test.js` : buffer explicite (activé/désactivé au clic, pour s'affranchir de la segmentation) + dictionnaire de conversion + validation de forme + compteur de conformité en direct ; nouvelle colonne `DCT_TexteNormalise` (ajoutée en base par Jean-Marc). En testant, deux bugs de robustesse préexistants découverts grâce à un bloc de diagnostic temporaire ajouté en direct (affichage de chaque démarrage/arrêt/erreur du moteur) : la fenêtre de test était entièrement vidée à chaque redémarrage automatique du moteur (invisible jusqu'ici, devenu très gênant avec une dictée hésitante) ; et une boucle de redémarrage incontrôlée pouvant atteindre plus de 1800 cycles en quelques secondes, sans aucune protection dans le code d'origine. Les deux corrigés le même soir (réinitialisation de fenêtre restreinte à l'ouverture volontaire ; coupe-circuit après 5 redémarrages rapides consécutifs). Sur un tout petit échantillon (4 essais) après correctifs : 2 adresses parfaitement normalisées, 1 quasi-bonne, 1 ratée par oubli de cliquer "Valider" entre deux adresses distinctes — encourageant, mais échantillon trop petit pour conclure. Piste d'une API payante avec vocabulaire personnalisé (Google Cloud, Deepgram) documentée et chiffrée en réserve, non retenue pour l'instant (coût en dollars négligeable, mais changement d'architecture réel). Version incrémentée une seule fois pour l'ensemble de cette session : **V1.011**. Jean-Marc prévoit une campagne de tests en nombre le 4 septembre et enverra les résultats. **3 septembre 2026 (nuit) : dictée d'adresses mail — diagnostic et deux bugs de robustesse corrigés dans l'outil de banque de dictées (V1.011).** Détail complet en section 27 du journal ; comparatif de prix des API de reconnaissance vocale en réflexions stratégiques, section 12 (addendum du même soir). Résumé : les premiers essais réels de Jean-Marc ont montré un taux d'échec très élevé (~1/50) sur la dictée d'une adresse mail. Diagnostic à partir d'exports réels de `ASP_DicteesTest` : trois causes distinctes — vocabulaire de ponctuation dicté ("point", "tiret", "arobase") converti de façon incohérente par le moteur gratuit du navigateur, segmentation de la dictée en plusieurs lignes non fiable, et des lettres purement avalées sur des mots sans support linguistique réel (ex. "mvl-tls.fr" → "mvtls.fr", perte acoustique jamais rattrapable après coup). Ajout d'un "mode adresse" dans `micro_test.js` : buffer explicite (activé/désactivé au clic, pour s'affranchir de la segmentation) + dictionnaire de conversion + validation de forme + compteur de conformité en direct ; nouvelle colonne `DCT_TexteNormalise` (ajoutée en base par Jean-Marc). En testant, deux bugs de robustesse préexistants découverts grâce à un bloc de diagnostic temporaire ajouté en direct (affichage de chaque démarrage/arrêt/erreur du moteur) : la fenêtre de test était entièrement vidée à chaque redémarrage automatique du moteur (invisible jusqu'ici, devenu très gênant avec une dictée hésitante) ; et une boucle de redémarrage incontrôlée pouvant atteindre plus de 1800 cycles en quelques secondes, sans aucune protection dans le code d'origine. Les deux corrigés le même soir (réinitialisation de fenêtre restreinte à l'ouverture volontaire ; coupe-circuit après 5 redémarrages rapides consécutifs). Sur un tout petit échantillon (4 essais) après correctifs : 2 adresses parfaitement normalisées, 1 quasi-bonne, 1 ratée par oubli de cliquer "Valider" entre deux adresses distinctes — encourageant, mais échantillon trop petit pour conclure. Piste d'une API payante avec vocabulaire personnalisé (Google Cloud, Deepgram) documentée et chiffrée en réserve, non retenue pour l'instant (coût en dollars négligeable, mais changement d'architecture réel). Version incrémentée une seule fois pour l'ensemble de cette session : **V1.011**. Jean-Marc prévoit une campagne de tests en nombre le 4 septembre et enverra les résultats.
**4 septembre 2026 : interprétation vocale des contacts par un LLM à sortie structurée (V1.012).** Détail complet en section 29 du journal. Résumé : premier outil réel d'interprétation, dans le prolongement de la banque de dictées "Gestion des Contacts" — `contact_ia_interprete.php` (appel OpenAI `gpt-5.6-luna`, JSON Schema strict construit à partir des tables réelles ASP_Statuts/ASP_Professions/ASP_Agents/ASP_Actions, re-validation défensive après réponse), `contact_ia_valide.php` (seul point d'écriture, table à part `ASP_PropositionsContact` — ne crée jamais de contact ni de profession réels), `contact_ia_panel.js` (panneau flottant sur le micro de l'écran de fond, remplace `micro_test.js`). Décision architecturale actée : l'attribution d'un assistant par défaut quand aucun n'est nommé relève d'un futur traitement n8n, pas de cette couche d'interprétation qui doit rester un enregistreur honnête de ce qui est réellement dit. Détection de l'action "ajouter le contact" (libellé fixe, toujours classée en premier) confirmée comme une vraie décision du LLM porté par le prompt, pas un cas codé en dur — vérifié avec Jean-Marc. Zone actions simplifiée en 3 champs de texte libre (au lieu d'un motif dynamique existant/nouveau par ligne, jugé trop lourd pour cet essai). Nombreuses itérations d'ergonomie du panneau : dimensionnement immédiat à la taille maximale disponible avec défilement unique, lisibilité des listes déroulantes, arrêt automatique du micro après 3,5s de silence, redémarrage automatique du micro et panneau qui reste ouvert après validation (pour dicter plusieurs contacts à la suite), bouton "Abandonner" retiré au profit d'Échap seul. Relecture d'un premier corpus réel (11 essais) : résultats globalement encourageants, un bug d'extraction identifié (adresse mail non nettoyée d'un mot d'appel et d'un accent) et une possible troncature de fin de dictée par le minuteur de silence restent des points ouverts, non corrigés à la clôture. Jean-Marc a par ailleurs formulé une réflexion pour une prochaine session : séparer formellement les actions utilisables par l'utilisateur humain (menu/clavier) de celles utilisables par le LLM vocal, avec un vocabulaire d'actions unique et cohérent entre les deux voies d'accès. Version incrémentée une seule fois pour l'ensemble de cette session : **V1.012**.
**Reste à faire, noté au passage :** **Reste à faire, noté au passage :**
- Décider s'il faut renforcer le prompt d'extraction pour neutraliser les mots parasites/formules de politesse dictés autour d'une adresse mail (bug identifié le 4 septembre sur le corpus réel, correction non encore actée par Jean-Marc).
- Clarifier l'origine de la coupure de fin de dictée observée sur un essai du 4 septembre (minuteur de silence à 3,5s vs interruption réelle) avant de décider si `DUREE_SILENCE_ARRET_MS` (`contact_ia_panel.js`) doit être allongée.
- Concevoir la séparation entre actions utilisables par l'utilisateur humain (menu/clavier) et actions utilisables par le LLM de saisie vocale, avec "création du contact" formalisée comme action à part entière toujours classée en premier et connue des deux voies d'accès — réflexion actée par Jean-Marc le 4 septembre, non conçue, non codée (voir section 29).
- Vérifier concrètement que le `.htaccess` du dossier `/journal` bloque bien l'accès direct (`curl -I` sur un fichier du dossier doit répondre `403`, pas `200` — point en suspens depuis le 31 août). - Vérifier concrètement que le `.htaccess` du dossier `/journal` bloque bien l'accès direct (`curl -I` sur un fichier du dossier doit répondre `403`, pas `200` — point en suspens depuis le 31 août).
- Confirmer le déploiement du correctif `.htaccess` de cache livré le 3 septembre (fichier `htaccess-racine-aspic.txt`, à renommer et déposer à la racine de l'application). - Confirmer le déploiement du correctif `.htaccess` de cache livré le 3 septembre (fichier `htaccess-racine-aspic.txt`, à renommer et déposer à la racine de l'application).
- Laisser Jean-Marc constituer un vrai volume de dictées sur "Gestion des Contacts" (nouvel outil, voir ci-dessus) avant toute analyse ; puis concevoir le catalogue d'actions et le mécanisme LLM à sortie structurée correspondant (voir réflexions stratégiques, section 12, et journal section 27). - Laisser Jean-Marc constituer un vrai volume de dictées sur "Gestion des Contacts" (nouvel outil, voir ci-dessus) avant toute analyse ; puis concevoir le catalogue d'actions et le mécanisme LLM à sortie structurée correspondant (voir réflexions stratégiques, section 12, et journal section 27).
@@ -160,3 +160,13 @@ Point explicitement acté avec Jean-Marc : **aucune solution, gratuite ou payant
- **Le coût en dollars est négligeable à l'usage envisagé** (quelques dollars par mois même à un usage quotidien soutenu) — ce n'est pas ce qui doit trancher. Ce qui coûte réellement, c'est le changement d'architecture : ces API ne s'utilisent pas depuis le navigateur comme aujourd'hui (gratuit, zéro serveur) — il faudrait capturer l'audio et le faire transiter par un point d'entrée serveur détenant une clé tenue secrète, fin de la solution 100% navigateur actuelle. - **Le coût en dollars est négligeable à l'usage envisagé** (quelques dollars par mois même à un usage quotidien soutenu) — ce n'est pas ce qui doit trancher. Ce qui coûte réellement, c'est le changement d'architecture : ces API ne s'utilisent pas depuis le navigateur comme aujourd'hui (gratuit, zéro serveur) — il faudrait capturer l'audio et le faire transiter par un point d'entrée serveur détenant une clé tenue secrète, fin de la solution 100% navigateur actuelle.
**Décision : épuiser d'abord la piste gratuite (dictionnaire de conversion + segmentation explicite au clic, "mode adresse" de `micro_test.js`, voir journal section 27) avant d'investir dans une API.** Pas de décision définitive sur le recours à une API — dépendra du taux d'échec réellement mesuré sur la campagne de tests que Jean-Marc prévoit de mener le 4 septembre. Si le taux reste insuffisant, Deepgram est le candidat à tester en premier (le moins cher, formatage inclus), Google Cloud restant l'option la plus directement conçue pour le problème de ponctuation dictée mais à vérifier en vrai plutôt que sur la seule doc, notamment pour la couverture du français. **Décision : épuiser d'abord la piste gratuite (dictionnaire de conversion + segmentation explicite au clic, "mode adresse" de `micro_test.js`, voir journal section 27) avant d'investir dans une API.** Pas de décision définitive sur le recours à une API — dépendra du taux d'échec réellement mesuré sur la campagne de tests que Jean-Marc prévoit de mener le 4 septembre. Si le taux reste insuffisant, Deepgram est le candidat à tester en premier (le moins cher, formatage inclus), Google Cloud restant l'option la plus directement conçue pour le problème de ponctuation dictée mais à vérifier en vrai plutôt que sur la seule doc, notamment pour la couverture du français.
## 13. L'interprétation structurée passe l'épreuve du réel, et fait émerger un nouveau besoin : séparer les actions "humaines" des actions "vocales" (4 septembre 2026)
**L'hypothèse de la section 12 est validée en conditions réelles, pas seulement sur le papier.** Premier outil réel d'interprétation d'une dictée par LLM à sortie structurée (voir journal de développement, section 29, pour le détail technique — `contact_ia_interprete.php`/`contact_ia_valide.php`/`contact_ia_panel.js`, jamais d'écriture dans les vraies tables de contacts, tout passe par une table à part `ASP_PropositionsContact`). Bilan de Jean-Marc sur un premier corpus réel de 11 essais, sans ambiguïté : *"c'est vraiment super ! Les tests sont vraiment encourageants."* Deux points restent ouverts (une adresse mail mal nettoyée d'un mot d'appel et d'un accent ; une possible troncature de fin de dictée par le nouveau minuteur de silence) mais ne remettent pas en cause l'approche elle-même — ce sont des réglages fins, pas un problème de méthode. Point vérifié explicitement avec Jean-Marc et à retenir comme garde-fou de confiance dans l'outil : la détection et le classement en premier de l'action "ajouter le contact" sont une vraie décision du LLM portée par le prompt, pas un cas particulier codé en dur côté Claude.
**Décision architecturale actée en cours de session, à retenir comme principe général pour toute future couche d'interprétation vocale :** cette couche doit rester un enregistreur honnête de ce qui a été réellement dit, jamais un lieu où s'invitent des règles métier. Concrètement : l'attribution d'un assistant par défaut quand aucun n'est nommé dans la dictée n'est **pas** de son ressort — cette décision reviendra à un futur traitement n8n, au moment de transformer une proposition validée en vrai post-it. Mélanger les deux aurait fait perdre la trace de ce que Jean-Marc a réellement dicté derrière une interprétation déjà "arrangée".
**Nouveau besoin architectural identifié par Jean-Marc à l'usage, non conçu ni codé — à reprendre dans une prochaine session.** En listant les actions qu'un contact peut porter (envoyer une doc, proposer un tarif...), Jean-Marc a réalisé que la création du contact lui-même mérite d'être une action à part entière au même titre que les autres — mais une action à double accès : le classement la place toujours en premier, elle est bien connue du LLM vocal (qui doit la détecter et la renvoyer classée), **et** l'utilisateur humain doit pouvoir la déclencher directement depuis un menu ou un raccourci clavier, sans passer par la voix. Jean-Marc souhaite généraliser ce principe à l'ensemble des actions d'ASPIC : une séparation formelle entre les actions utilisables par l'utilisateur humain (menu/écran classique) et celles utilisables par le LLM de saisie vocale, tout en gardant les deux voies d'accès parfaitement alignées sur un même vocabulaire d'actions et un même ordre. C'est précisément le motif qui structure sa satisfaction du jour : *"ce qui me plaît c'est que je vois bien que j'arrive à suivre ma logique : des fonctionnalités accessibles via des menus et écrans de façon tout à fait classique et ces mêmes fonctionnalités accessibles par des ordres vocaux fiables donnés à des assistants !"* — une confirmation, par la pratique, du principe de double accès déjà esquissé dans la vision "bureau à l'ancienne" (section 11) : mêmes fonctionnalités, deux portes d'entrée. Rien n'est conçu ni codé sur ce point à ce stade ; voir journal de développement, section 29, pour le détail du besoin tel que formulé.
**Situation acceptée comme normale, pas un défaut à corriger : une profession peut être proposée "nouvelle" plusieurs fois avant traitement.** Deux dictées distinctes du corpus ont fait remonter deux fois la même profession ("Informaticien") comme nouvelle. Jean-Marc a tranché sans hésitation : c'est acceptable tant qu'un assistant n'a pas traité le post-it — il suffira, au moment du traitement, de vérifier si la profession n'a pas déjà été créée entre l'enregistrement de la note vocale et son traitement. Pas de mécanisme de déduplication à construire à ce stade.
@@ -0,0 +1,68 @@
# 29. Interprétation vocale des contacts par LLM
*Section démarrée le 4 septembre 2026, dans le prolongement direct de la section 27 (Pilotage vocal) et de la banque de dictées constituée sur "Gestion des Contacts" (`micro_test.js`/`dictees_test.php`, table `ASP_DicteesTest`, voir V1.010-V1.011). Objectif de cette session : passer de la dictée brute enregistrée à une première interprétation réelle par LLM, sans jamais créer de contact réel — c'est le chantier annoncé en réflexions stratégiques, section 12.*
## Principe retenu et périmètre volontairement restreint
Jean-Marc a validé dès le départ le principe déjà posé en réflexions stratégiques : un LLM à sortie structurée (JSON Schema strict), pas de RAG/pgvector pour cet usage (réservé à la recherche floue), catalogue d'actions comparé explicitement en prompt plutôt que deviné. Nouvel outil de mise au point, dans la continuité de `contact_ia_test.html`/`.js` (test antérieur, conservé tel quel, aujourd'hui largement dépassé par ce qui suit) : `contact_ia_interprete.php` (appel LLM), `contact_ia_valide.php` (enregistrement), `contact_ia_panel.js` (panneau flottant, remplace `micro_test.js` sur `#micro-desk`).
**Garde-fou explicite, rappelé plusieurs fois en session et documenté en tête de chaque fichier : cette chaîne n'écrit jamais dans `ASP_Contacts` ni `ASP_Professions`.** Tout passe par une table à part, `ASP_PropositionsContact` (`PCT_Incr`, `PCT_TexteBrut`, `PCT_Extraction` JSON, `PCT_Valide` JSON, `PCT_Utilisateur`, `PCT_Date`, créée par Jean-Marc directement en base, jamais par Claude). `PCT_Extraction` est la sortie brute et honnête du LLM, jamais retouchée après coup ; `PCT_Valide` est ce que Jean-Marc valide réellement après relecture — les deux ont vocation à diverger dans leur forme, c'est voulu, rien n'est perdu silencieusement (voir plus bas, redesign des actions).
## `contact_ia_interprete.php` — extraction structurée
Lit à chaque appel `ASP_Statuts`, `ASP_Professions`, `ASP_Agents` (`AGN_Incr, AGN_Nom, AGN_Fonction, AGN_Variantes`, dans cet ordre — voir plus bas pourquoi `AGN_Defaut` n'y figure pas) et `ASP_Actions`, pour construire un JSON Schema strict envoyé à OpenAI (`gpt-5.6-luna`, choisi pour son coût — modèle plus capable `gpt-5.6-terra` documenté en réserve si la qualité d'extraction se révèle insuffisante à l'usage réel, non activé pour l'instant). Le schéma couvre les champs classiques d'un contact, `profession_existante_id`/`profession_nouvelle_libelle`, `assistant_id`, et un tableau `actions[]` (voir ci-dessous). Re-validation défensive côté PHP après réponse du LLM : `CNT_Statut`, `assistant_id` et chaque `action_existante_code` sont revérifiés contre les valeurs réelles de la base avant renvoi au client — le LLM ne peut jamais faire accepter une valeur qui n'existe pas vraiment.
**Décision architecturale actée en session : l'attribution d'un assistant par défaut n'est pas le rôle de cette couche.** Premier réflexe de Claude : pré-sélectionner l'assistant par défaut (`AGN_Defaut`, aujourd'hui Matilda) côté serveur ou côté panneau quand aucun nom n'est dicté. Correction de Jean-Marc : cette couche doit rester un enregistreur honnête de ce qui a été réellement dit ; les règles métier (à qui attribuer une tâche sans assistant nommé) relèvent d'un futur traitement n8n qui transformera les propositions validées en vrais post-its, pas de cette interprétation. Revert : colonne `AGN_Defaut` retirée de la requête SQL (redevenue identique à celle d'`agents.php`), toute logique de pré-sélection retirée de `contact_ia_panel.js`, décision documentée en commentaire dans les deux fichiers.
**Détection et classement de l'action "ajouter le contact" — confirmée comme une vraie décision du LLM, pas un cas codé en dur** (question posée explicitement par Jean-Marc en fin de session, vérifiée avec lui dans le prompt réel). La consigne du prompt système précise que la création du contact lui-même ("ajoute ce contact", "crée-le", "enregistre ce contact"...) est une action à part entière, sans code dans `ASP_Actions`, à retourner avec le libellé fixe `"Ajouter le contact"` dans `action_nouvelle_libelle` (formulation figée, pour rester comparable d'une dictée à l'autre) — et que, lorsqu'elle est demandée, elle doit toujours être placée en premier dans le tableau `actions`, avant les autres actions éventuelles, car c'est en général la première chose voulue chronologiquement. Un tri côté client dans `contact_ia_panel.js` (voir plus bas) sert uniquement de filet de sécurité si le LLM ne respectait pas l'ordre, pas de mécanisme de détection.
## `contact_ia_valide.php` — le seul point d'écriture
`INSERT` unique dans `ASP_PropositionsContact`, POST JSON `{texteBrut, extraction, valide}`. Ne relit ni ne valide le contenu de `valide` (colonne JSON opaque). En-tête mis à jour en session pour documenter un changement de forme sans conséquence de schéma : `valide.actions` est passé, le même soir, d'un tableau d'objets `{action_existante_code, action_nouvelle_libelle}` à un simple tableau de libellés texte, suite au redesign de la zone actions (voir ci-dessous) — point à connaître pour qui construira plus tard le consommateur n8n de cette table.
## `contact_ia_panel.js` — le panneau flottant, nombreuses itérations dans la session
Remplace `micro_test.js` sur `#micro-desk` (`ecran_fond.html`). Construit un panneau flottant (`#panneau-contact-ia`) avec une prompt terminal en deux temps ("attente" curseur clignotant, puis "prêt" — micro ouvert, parlez), la restitution de la dictée brute, et un formulaire d'analyse (identité, statut, profession avec repli "nouvelle profession", coordonnées, assistant, actions, bouton Valider unique).
**Simplification de la zone actions.** Premier essai avec un motif complexe (une ligne par action détectée, choix dynamique existant/nouveau par ligne) jugé trop lourd pour un essai vocal rapide — objectif de cette phase étant justement de repérer les actions non encore couvertes par `ASP_Actions`, pas de construire une UI de gestion aboutie. Remplacé par 3 zones de texte libre, pré-remplies avec le texte résolu de chaque action détectée (code existant → son libellé, ou libellé nouveau du LLM), triées pour que "Ajouter le contact" arrive toujours en premier (filet de sécurité, voir plus haut) ; au-delà de 3 actions détectées, les suivantes restent intactes dans `PCT_Extraction` mais non éditables dans l'UI pour cet essai. `collecterActionsValidees()` renvoie désormais un simple tableau de chaînes.
**Dimensionnement et défilement du panneau.** Demande de Jean-Marc : que la fenêtre prenne tout de suite sa taille maximale disponible (jusqu'au bas de l'écran sous le bouton micro), l'ascenseur apparaissant ensuite si besoin, sur toute la hauteur de la fenêtre — y compris au prix de faire disparaître les deux premières lignes de prompt en cas de défilement. Résolu en fusionnant l'en-tête de prompt, la zone de restitution et le formulaire de résultat en une seule région scrollable (`.contenu-scrollable`, `flex:1; min-height:0; overflow-y:auto`) à l'intérieur d'une colonne flex de hauteur fixe, celle-ci calculée immédiatement à l'ouverture par `ajusterHauteurPanneau()` (hauteur, pas max-height, basée sur la position réelle du bouton micro moins une marge), recalculée au redimensionnement de fenêtre. Ascenseur discret personnalisé (fin, sombre) sur cette même zone.
**Lisibilité des `<option>` des listes déroulantes.** Signalé par Jean-Marc : texte blanc sur fond blanc, et blanc sur fond bleu pour l'option survolée/sélectionnée, sur le statut comme sur la profession. Corrigé par un style explicite (`option{background:#1a1a1a;color:#999}`, `option:checked,option:hover{background:#999;color:#111}`) — réserve honnête : Chrome conserve nativement sa propre surbrillance de survol pour l'option "active" quelle que soit la CSS appliquée (Firefox la respecte mieux), donc le résultat n'est pas identique à 100% d'un navigateur à l'autre, mais le cas bloquant (texte invisible) est bien résolu partout.
**Micro fermé après Valider, panneau qui se fermait aussi.** Deux frictions remontées ensemble par Jean-Marc en test réel : *"valider sort de la fenêtre ce qui n'est pas pratique pour en dicter plusieurs à la suite"*, puis *"on revient en écran vierge, mais il ne se passe rien et je pense que le micro est fermé"* (diagnostic confirmé exact). Corrigé en deux temps : le panneau ne se ferme plus jamais après un Valider réussi (retrait de l'appel qui masquait `#panneau-contact-ia`) ; une nouvelle fonction partagée `demarrerEcoute()` (factorisée depuis le clic du bouton micro) est désormais appelée automatiquement 1,8s après un Valider réussi si le micro n'a pas déjà été redémarré manuellement entre-temps — le panneau reste ouvert, prêt pour la dictée suivante, sans manipulation.
**Bouton "Abandonner" retiré.** Faisait double emploi avec la touche Échap (déjà en place pour fermer le panneau) — retiré (bouton, CSS, variable, gestionnaire), `abandonnerPanneau()` conservée telle quelle, appelée uniquement par le gestionnaire Échap.
**Arrêt automatique sur silence.** Ajout d'un minuteur de silence (`DUREE_SILENCE_ARRET_MS = 3500`, "3-4 secondes" demandées par Jean-Marc), armé seulement après le premier résultat de reconnaissance (pas dès l'ouverture du micro), réinitialisé à chaque nouveau résultat, traité comme un arrêt volontaire propre au bout de 3,5s sans nouvelle parole. Coexiste avec le coupe-circuit anti-boucle déjà en place (5 redémarrages en moins de 800ms) et l'arrêt d'inactivité absolue à 10 minutes (hérités de `micro_test.js`).
**Garde défensive ajoutée suite à une erreur en test réel.** Jean-Marc a rencontré `Cannot read properties of undefined (reading 'forEach')` en cours d'essai. Revue statique du code n'a pas permis d'isoler une cause certaine (PDO en `ERRMODE_EXCEPTION`, requête agents identique à celle, déjà fiable, d'`agents.php`) — protection ajoutée par prudence : gardes `(x || [])` sur `remplirStatuts`, `remplirProfessions` et `remplirAssistant`, sur le même principe que celui déjà en place dans `remplirActions`. Aucune récidive rapportée depuis.
**Fausse alerte : dropdown assistant vide.** Investigation engagée après un signalement de liste déroulante d'assistants vide malgré une base réellement peuplée (6 agents, 4 actifs) — hypothèse un temps envisagée d'un mauvais routage MIRADOR entre bases. Une option de diagnostic *"(aucun assistant en base)"* a été ajoutée dans `remplirAssistant()` en cours d'investigation (conservée, coût nul). Conclusion de Jean-Marc lui-même : *"c'est une erreur de copier/coller"* de son côté, pas un bug — refermé sans autre correction.
### Version applicative
`V1.012` pour l'ensemble de cette session (une seule incrémentation, conformément à la règle du journal).
## Relecture du corpus réel (11 premiers essais, fin de session)
Jean-Marc a transmis un export SQL de `ASP_PropositionsContact` (11 lignes) pour un premier bilan qualité. Constat global positif — Jean-Marc : *"les tests sont vraiment encourageants."* Points relevés par Claude à cette relecture :
- **"Ajouter le contact" confirmée fonctionnelle et bien classée** à partir de l'essai n°7 (les tout premiers essais précédaient la mise en place de la règle de classement).
- **Bug d'extraction identifié, non corrigé** : sur l'essai n°2, l'adresse mail extraite est `"Monsieur Paul lefébure@gmail.com"` — le LLM n'a ni retiré le mot d'appel dicté avant l'adresse ("Monsieur Paul"), ni supprimé l'accent sur "lefébure" pourtant couvert par une consigne explicite du prompt sur les adresses mail sans accent. Jean-Marc a dû corriger à la main. **Piste proposée par Claude, pas encore mise en œuvre** : renforcer la consigne du prompt pour aussi neutraliser les mots parasites/formules de politesse autour d'une adresse dictée, en plus de la règle d'accents déjà en place — en attente d'un feu vert explicite de Jean-Marc.
- **Coupure possible en fin de dictée** : le texte brut de l'essai n°7 se termine en plein mot ("...aspic son"), ce qui pourrait indiquer une troncature par le nouveau minuteur de silence (3,5s). Question posée à Jean-Marc ; sa réponse évoque une autre piste possible (le chat venant parfois le déranger à son bureau pendant qu'il dicte) sans trancher explicitement si ce cas précis vient du chat ou du minuteur, ni donner d'instruction de changer la valeur. **Point resté ouvert**, `DUREE_SILENCE_ARRET_MS` non modifiée dans l'attente d'un diagnostic plus net.
- **Profession "Informaticien" apparue deux fois comme nouvelle profession** sur des dictées différentes du corpus. Jean-Marc, interrogé, a confirmé que c'est une situation acceptable et attendue en l'état : une profession peut être marquée "nouvelle" plusieurs fois tant qu'un assistant n'a pas traité le post-it correspondant ; le traitement du post-it devra simplement vérifier si la profession n'a pas déjà été créée entre l'enregistrement de la note vocale et son traitement. Pas de changement de code nécessaire pour ce point.
- Différences ponctuelles `null` vs chaîne vide entre `PCT_Extraction` et `PCT_Valide` : jugées normales et sans gravité (simplification attendue au passage par le formulaire de validation).
## Point de conception pour une prochaine session (noté par Jean-Marc, non conçu ni codé)
En clôture, Jean-Marc a formulé une réflexion architecturale à reprendre plus tard : la notion d'action sur un contact devra probablement évoluer pour intégrer formellement "création du contact" comme une action à part entière — dont le classement la place toujours en premier — mais qui ne serait pas accessible **que** par le LLM : l'utilisateur humain doit aussi pouvoir la déclencher depuis un menu ou un raccourci clavier. Il faut cependant que cette action reste connue du LLM, et que le retour de celui-ci vienne s'insérer dans le même classement ordonné, pour que les ordres passés à l'assistant vocal restent cohérents avec ceux disponibles par les écrans classiques. Jean-Marc souhaite généraliser ce principe à l'ensemble des autres actions : une séparation claire entre les actions utilisables par l'utilisateur humain (menu/clavier) et celles utilisables par le LLM de saisie vocale, tout en gardant un vocabulaire d'actions unique et cohérent entre les deux voies d'accès — écho direct du constat de satisfaction qu'il tire de cette journée : *"ce qui me plaît c'est que je vois bien que j'arrive à suivre ma logique : des fonctionnalités accessibles via des menus et écrans de façon tout à fait classique et ces mêmes fonctionnalités accessibles par des ordres vocaux fiables donnés à des assistants !"* Non conçu, non codé — à reprendre dans une prochaine session.
## Reste à faire, noté au passage
- Décider (avec l'accord explicite de Jean-Marc, pas encore donné) s'il faut renforcer le prompt d'extraction pour neutraliser les mots parasites/formules de politesse dictés autour d'une adresse mail, en plus de la règle d'accents déjà en place.
- Clarifier l'origine de la coupure en fin de dictée observée sur l'essai n°7 (minuteur de silence à 3,5s vs interruption réelle) avant de décider si `DUREE_SILENCE_ARRET_MS` doit être allongée.
- Concevoir la séparation "actions utilisables par l'utilisateur humain (menu/clavier)" / "actions utilisables par le LLM de saisie vocale", avec une notion de "création du contact" formalisée comme action à part entière toujours classée en premier, connue des deux voies d'accès et cohérente entre elles (voir ci-dessus) — réflexion actée par Jean-Marc, non conçue, non codée.
- Poursuivre la constitution du corpus réel sur "Gestion des Contacts" (Jean-Marc prévoit de réutiliser ce même écran dès le lendemain pour des demandes de modification/suppression de contact) et suivre si le taux de doublons de nouvelles professions reste gérable en pratique au fil du volume.
- Étendre progressivement, comme annoncé par Jean-Marc, ce même principe d'écran (dictée + interprétation LLM + panneau de validation) aux autres applications d'ASPIC.
- Rappel toujours valable (sections 26/27/28) : `ASP_ToDoList`/`todolist.php` doivent être étendus pour qu'une tâche puisse être réellement attribuée à un assistant avant qu'un vrai post-it puisse être généré à partir d'une proposition validée de cette table — prérequis explicitement posé par Jean-Marc avant d'aller plus loin sur le traitement n8n.
+1 -1
View File
@@ -24,7 +24,7 @@
* d'identification, avant toute connexion. * d'identification, avant toute connexion.
*/ */
const ASPIC_VERSION = 'V1.011'; const ASPIC_VERSION = 'V1.012';
if (basename($_SERVER['SCRIPT_NAME']) === 'version.php') { if (basename($_SERVER['SCRIPT_NAME']) === 'version.php') {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');