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
+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]);
}
});
})();