From ba8903d87b6f368763faebdcdcdf137c18e19c73 Mon Sep 17 00:00:00 2001 From: Jean-Marc Date: Sat, 5 Sep 2026 05:44:30 +0000 Subject: [PATCH] =?UTF-8?q?V1.012=20D=C3=A9but=20du=20traitement=20par=20I?= =?UTF-8?q?A=20des=20retranscriptions=20des=20dict=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contact_ia_interprete.php | 386 ++++++++ contact_ia_panel.js | 847 ++++++++++++++++++ contact_ia_test.html | 161 ++++ contact_ia_test.js | 364 ++++++++ contact_ia_valide.php | 109 +++ ecran_fond.html | 40 +- journal/ASPIC - Journal. Index.md | 10 +- journal/ASPIC - Reflexions strategiques.md | 10 + ...erprétation Vocale des Contacts par LLM.md | 68 ++ version.php | 2 +- 10 files changed, 1975 insertions(+), 22 deletions(-) create mode 100644 contact_ia_interprete.php create mode 100644 contact_ia_panel.js create mode 100644 contact_ia_test.html create mode 100644 contact_ia_test.js create mode 100644 contact_ia_valide.php create mode 100644 journal/ASPIC-Journal. 29. Interprétation Vocale des Contacts par LLM.md diff --git a/contact_ia_interprete.php b/contact_ia_interprete.php new file mode 100644 index 0000000..85e7cd8 --- /dev/null +++ b/contact_ia_interprete.php @@ -0,0 +1,386 @@ + '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 = << 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, +]); diff --git a/contact_ia_panel.js b/contact_ia_panel.js new file mode 100644 index 0000000..048fe51 --- /dev/null +++ b/contact_ia_panel.js @@ -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@ : ", 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@ : " + * 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@ : 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