mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
710 lines
35 KiB
JavaScript
710 lines
35 KiB
JavaScript
/**
|
|
* ASPIC — micro.js
|
|
*
|
|
* Comportement du bouton micro (#bouton-micro), partagé par tous les
|
|
* écrans qui l'affichent — chargé via <script src="micro.js"></script> en
|
|
* toute fin de page, après le script propre à chaque écran.
|
|
*
|
|
* Pourquoi un fichier séparé plutôt que dupliqué dans le gabarit comme le
|
|
* badge société ou l'avatar : ceux-là ne sont que de la présentation, alors
|
|
* qu'ici il s'agit de comportement (reconnaissance vocale, puis
|
|
* interprétation de commandes) qui doit rester rigoureusement identique
|
|
* partout, sur un nombre d'écrans appelé à grandir — une correction ne
|
|
* doit se faire qu'à un seul endroit. Nommé "micro.js" plutôt que "voix.js"
|
|
* car appelé à porter d'autres fonctionnalités liées à la voix au fil des
|
|
* prochains chantiers, pas seulement la reconnaissance.
|
|
*
|
|
* Chaque page hôte doit déjà contenir la structure suivante dans son
|
|
* bandeau (voir liste_contacts.html / liste_todolist.html) :
|
|
* #bouton-micro > #icone-micro (svg, icône micro barré) + #barres-ecoute
|
|
* et peut définir une fonction globale afficherToast(message) pour le
|
|
* retour visuel — micro.js s'en sert si elle existe, sinon se contente
|
|
* d'un console.log (utile en test avant d'avoir une page complète).
|
|
*
|
|
* Étape 1 (1er septembre 2026) : reconnaissance vocale réelle branchée sur
|
|
* le bouton, texte transcrit simplement affiché en retour pour valider le
|
|
* circuit micro -> texte. Aucune commande interprétée ni action
|
|
* déclenchée à ce stade — ce sera l'étape 2 (ex. ajout de tâche à la
|
|
* ToDoList), une fois ces premiers tests concluants. Le stockage des
|
|
* textes entendus dans une table dédiée, évoqué par Jean-Marc pour la
|
|
* suite, n'est pas non plus branché ici — à ajouter une fois la structure
|
|
* de table définie avec lui.
|
|
*
|
|
* Mode utilisé : reconnaissance "cloud" du navigateur (SpeechRecognition /
|
|
* webkitSpeechRecognition) — mature et gratuite, au prix d'un envoi de
|
|
* l'audio à un service externe (voir réflexions stratégiques, section 3).
|
|
* Bascule vers le mode local ("on-device", encore expérimental côté Edge à
|
|
* ce jour) envisageable plus tard sans changer la structure de ce fichier.
|
|
*
|
|
* Mot déclencheur "Lilou" (1er septembre 2026) : en écoute continue,
|
|
* chaque phrase captée est transcrite — y compris une conversation de fond
|
|
* non destinée à ASPIC. Plutôt que de réagir à tout, seules les phrases
|
|
* commençant par "Lilou" (le prénom donné à l'assistante vocale d'ASPIC,
|
|
* clin d'œil au Cinquième Élément — Viktor s'occupant déjà des contacts
|
|
* côté commercial) sont prises en compte ; le reste est ignoré en
|
|
* silence. Nuance à garder en tête : ce filtre s'applique sur le texte
|
|
* déjà transcrit, pas avant l'envoi au service de reconnaissance — le son
|
|
* continue d'être transmis tant que le micro est ouvert, qu'on y trouve
|
|
* "Lilou" ou non (voir réflexions stratégiques pour la discussion sur le
|
|
* mode local, seule vraie façon d'éviter cet envoi).
|
|
*
|
|
* Mode "notation" — capture d'une note sans interprétation (1er septembre
|
|
* 2026, demande explicite de Jean-Marc) : une phrase d'ouverture ("Lilou,
|
|
* ajoute une tâche...", "Lilou, un truc à faire...", "Lilou, note ça dans
|
|
* ma todolist...") fait basculer dans un mode où le "Lilou" en tête n'est
|
|
* plus exigé pour chaque ligne suivante — tout ce qui est capté est ajouté
|
|
* tel quel au fichier, jusqu'à une phrase de clôture ("c'est tout Lilou")
|
|
* ou d'annulation ("Lilou annule"), qui produisent exactement le même
|
|
* comportement (on enregistre ce qui a été dit dans les deux cas — pas de
|
|
* traitement différencié pour l'annulation, sur demande explicite de
|
|
* Jean-Marc : lui analysera ses propres essais après coup). "ensuite" en
|
|
* fin de phrase sert de marqueur oral de fin de ligne, retiré du texte
|
|
* conservé. Volontairement SANS interprétation à ce stade — le texte part
|
|
* tel quel dans la table ASP_ConsignesVoix (via lilou_notes.php ; le
|
|
* fichier texte notes_lilou/dictees.txt de la V1.005 est abandonné depuis
|
|
* le 2 septembre 2026, voir journal section 27) pour constituer un corpus
|
|
* de vrais essais, avant de définir des règles d'interprétation dans un
|
|
* prochain chantier (étape 2, envisagée via n8n).
|
|
* Les phrases déclenchantes ci-dessous sont volontairement en dur et
|
|
* simples (pas de grammaire compliquée) — à ajuster une fois les premiers
|
|
* essais de Jean-Marc faits.
|
|
*
|
|
* Correctif du 2 septembre 2026 (premier essai réel en table SQL) : quand
|
|
* l'ouverture ou la clôture/annulation est dite dans la même phrase que du
|
|
* contenu, sans pause ("Lilou, ajoute une tâche, [contenu]" ou "[contenu],
|
|
* c'est tout Lilou, [contenu]" tout d'une traite), le contenu qui suit la
|
|
* phrase de contrôle repérée était perdu silencieusement — seul le contenu
|
|
* qui la précédait (cas déjà traité pour la clôture depuis le 1er
|
|
* septembre) était conservé. Corrigé pour capturer aussi ce qui suit,
|
|
* côté ouverture ET clôture/annulation, symétriquement. Micro coupé
|
|
* manuellement pendant une notation en cours (clôture orale oubliée ou non
|
|
* reconnue) : traité désormais comme une clôture normale plutôt que comme
|
|
* un abandon silencieux du contenu déjà dicté.
|
|
*
|
|
* Multi-agents et coupure pour inactivité (2 septembre 2026, suite du
|
|
* premier essai réel en table SQL) : le mot déclencheur unique "Lilou" est
|
|
* remplacé par une liste d'agents nommés (ASP_Agents : Lilou, Viktor,
|
|
* Matilda, Léon, Karl, Nikita), chargée depuis agents.php au démarrage de
|
|
* ce script — voir agentsDisponibles/chargerAgents plus bas. Le nom
|
|
* reconnu en tête de la phrase d'ouverture détermine l'agent adressé pour
|
|
* toute la notation qui suit (agentCourant), et part avec chaque ligne
|
|
* enregistrée dans CVX_AgentTraitant (voir agent_notes.php, ex-lilou_notes.
|
|
* php, renommé le même jour puisqu'il ne sert plus un seul agent).
|
|
*
|
|
* Variantes de prononciation (2 septembre 2026, généralisé après 3 cas
|
|
* réels indépendants le même jour — Lilou/Milou/Loulou, Matilda/Mathilda,
|
|
* Karl/Carl) : plutôt que du code en dur par agent, chaque ligne de
|
|
* ASP_Agents porte une colonne AGN_Variantes (graphies alternatives
|
|
* séparées par une virgule) intégrée au motif de reconnaissance — voir
|
|
* construireAgent. Jean-Marc peut donc ajuster une graphie lui-même en
|
|
* base, sans repasser par du code. Seule exception qui reste codée en dur :
|
|
* l'homophone "les loups" pour la clôture de Lilou ("c'est tout Lilou" →
|
|
* "c'est tous les loups"), qui n'est pas une variante du prénom seul mais
|
|
* de la phrase de clôture entière. Règle à respecter en choisissant une
|
|
* variante : uniquement des noms propres ou mots rares en tête de phrase,
|
|
* jamais un mot français courant ("car", "calme"...) qui déclencherait
|
|
* l'agent sur n'importe quelle phrase du quotidien commençant par ce mot —
|
|
* refusé explicitement pour Karl ce jour-là malgré des essais réels ratés
|
|
* avec "car" et "calme".
|
|
*
|
|
* Coupure pour inactivité (même jour) : par ailleurs,
|
|
* suite à un incident réel où le micro est resté ouvert pendant un appel
|
|
* téléphonique confidentiel (rien d'enregistré, sinon quelques traces de
|
|
* debug déjà retirées depuis — voir plus bas), une coupure automatique du
|
|
* micro après quelques minutes d'inactivité (DUREE_INACTIVITE_MS) a été
|
|
* ajoutée, sur le même principe qu'une fermeture manuelle.
|
|
*
|
|
* Écoute "tant qu'on ne l'a pas coupée soi-même" (1er septembre 2026,
|
|
* ajusté après un premier essai de Jean-Marc) : `continuous = true` ne
|
|
* suffit pas à lui seul — de façon documentée (y compris sur Chrome
|
|
* desktop, pas seulement mobile), le moteur du navigateur peut mettre fin
|
|
* à l'écoute de lui-même après un silence prolongé ou au bout d'un certain
|
|
* temps, sans que ce soit une erreur ni une action de l'utilisateur. On
|
|
* distingue donc "l'utilisateur veut que le micro soit ouvert"
|
|
* (ecouteVoulue) de l'état réel du moteur : si celui-ci s'arrête tout seul
|
|
* alors que l'écoute est toujours voulue, on le relance aussitôt — de façon
|
|
* invisible pour l'utilisateur, qui n'a donc à cliquer qu'une fois pour
|
|
* ouvrir le micro et une fois pour le refermer. Exception : une erreur
|
|
* bloquante (micro refusé, pas de micro détecté) ne redémarre pas
|
|
* automatiquement, pour ne pas boucler sans fin sur un blocage qui ne se
|
|
* résoudra pas tout seul.
|
|
*/
|
|
(function(){
|
|
"use strict";
|
|
|
|
const bouton = document.getElementById("bouton-micro");
|
|
if(!bouton) return; // écran sans bouton micro dans son bandeau
|
|
|
|
function notifier(message){
|
|
if(typeof window.afficherToast === "function"){
|
|
window.afficherToast(message);
|
|
} else {
|
|
console.log("[micro.js]", message);
|
|
}
|
|
}
|
|
|
|
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
if(!ReconnaissanceVocale){
|
|
// Navigateur sans support de l'API : bouton laissé visible (cohérence
|
|
// du bandeau) mais explicite sur l'indisponibilité au clic.
|
|
bouton.title = "Reconnaissance vocale non disponible sur ce navigateur";
|
|
bouton.addEventListener("click", function(){
|
|
notifier("La reconnaissance vocale n'est pas disponible sur ce navigateur.");
|
|
});
|
|
return;
|
|
}
|
|
|
|
const reconnaissance = new ReconnaissanceVocale();
|
|
reconnaissance.lang = "fr-FR";
|
|
reconnaissance.continuous = true;
|
|
reconnaissance.interimResults = false;
|
|
reconnaissance.maxAlternatives = 1;
|
|
|
|
// ecouteVoulue : ce que l'utilisateur a demandé (vrai entre un clic
|
|
// d'ouverture et le clic de fermeture, quels que soient les redémarrages
|
|
// silencieux du moteur entre les deux).
|
|
let ecouteVoulue = false;
|
|
// erreurBloquante : mise à vrai par une erreur qui ne se résoudra pas en
|
|
// relançant tout de suite (micro refusé, pas de micro détecté) — empêche
|
|
// le redémarrage automatique sur l'événement "end" qui suit.
|
|
let erreurBloquante = false;
|
|
|
|
// Coupure automatique après inactivité (2 septembre 2026, demande de
|
|
// Jean-Marc suite à l'incident du micro resté ouvert pendant un appel
|
|
// téléphonique confidentiel — voir journal section 27) : si aucune
|
|
// interaction réelle avec un agent (nom reconnu en tête de phrase, ou
|
|
// ligne captée en mode notation) n'a eu lieu depuis DUREE_INACTIVITE_MS,
|
|
// le micro se referme tout seul, comme un clic manuel de fermeture — pour
|
|
// limiter la fenêtre pendant laquelle un micro oublié ouvert continue
|
|
// d'envoyer l'audio ambiant au service de reconnaissance. Le bruit de
|
|
// fond ignoré en silence (aucun agent reconnu) ne repousse volontairement
|
|
// PAS ce délai : c'est précisément le cas qu'on veut détecter (une
|
|
// conversation qui ne s'adresse à aucun agent). Durée choisie arbitraire
|
|
// pour ce premier essai, à ajuster avec Jean-Marc selon l'usage réel.
|
|
const DUREE_INACTIVITE_MS = 10 * 60 * 1000; // 10 minutes
|
|
let minuteurInactivite = null;
|
|
|
|
function reinitialiserMinuteurInactivite(){
|
|
if(minuteurInactivite) clearTimeout(minuteurInactivite);
|
|
minuteurInactivite = setTimeout(couperPourInactivite, DUREE_INACTIVITE_MS);
|
|
}
|
|
|
|
function arreterMinuteurInactivite(){
|
|
if(minuteurInactivite){
|
|
clearTimeout(minuteurInactivite);
|
|
minuteurInactivite = null;
|
|
}
|
|
}
|
|
|
|
function couperPourInactivite(){
|
|
minuteurInactivite = null;
|
|
if(!ecouteVoulue) return; // déjà refermé entre-temps, rien à faire
|
|
// Même traitement que la coupure manuelle (voir le clic sur le bouton
|
|
// plus bas) : une notation en cours n'est pas perdue silencieusement,
|
|
// elle est clôturée avec un marqueur explicite avant l'arrêt.
|
|
if(modeNotation){
|
|
ajouterAuTampon("(coupure automatique pour inactivité)", "cloture");
|
|
terminerNotation();
|
|
}
|
|
ecouteVoulue = false;
|
|
notifier("Micro coupé automatiquement après " + Math.round(DUREE_INACTIVITE_MS / 60000) + " minutes d'inactivité.");
|
|
reconnaissance.stop();
|
|
}
|
|
|
|
function passerEnEcoute(){
|
|
bouton.classList.add("ecoute");
|
|
bouton.title = "Le micro est ouvert";
|
|
reinitialiserMinuteurInactivite();
|
|
}
|
|
|
|
function revenirAuRepos(){
|
|
ecouteVoulue = false;
|
|
arreterMinuteurInactivite();
|
|
bouton.classList.remove("ecoute");
|
|
bouton.title = "Le micro est inactif";
|
|
}
|
|
|
|
bouton.addEventListener("click", function(){
|
|
if(ecouteVoulue){
|
|
// Fermeture demandée par l'utilisateur : le retour au repos se fait
|
|
// via l'événement "end" qui va suivre, sans redémarrage puisque
|
|
// ecouteVoulue passe à faux avant l'appel à stop().
|
|
ecouteVoulue = false;
|
|
// Coupure manuelle du micro alors qu'une notation était en cours
|
|
// (clôture orale oubliée, ou "c'est tout Lilou" non reconnu) : on ne
|
|
// perd pas le contenu déjà dicté — même traitement qu'une clôture
|
|
// normale, plutôt qu'un abandon silencieux (retour de Jean-Marc, 2
|
|
// septembre 2026 : du contenu dicté ne s'est pas retrouvé enregistré).
|
|
// Marqueur de clôture explicite ajouté (type "cloture") pour que le
|
|
// lot envoyé garde la même forme qu'une clôture orale, même sans
|
|
// phrase de clôture réellement entendue.
|
|
if(modeNotation){
|
|
ajouterAuTampon("(coupure manuelle du micro)", "cloture");
|
|
terminerNotation();
|
|
}
|
|
reconnaissance.stop();
|
|
return;
|
|
}
|
|
ecouteVoulue = true;
|
|
erreurBloquante = false;
|
|
try{
|
|
reconnaissance.start();
|
|
}catch(erreur){
|
|
// start() peut lever une exception synchrone : soit un cycle déjà en
|
|
// cours côté navigateur (double-clic très rapide, sans conséquence),
|
|
// soit un vrai blocage (page pas en contexte sécurisé, permission
|
|
// micro refusée avant même le début d'écoute selon le navigateur).
|
|
// Dans le doute on prévient plutôt que de rester silencieux : mieux
|
|
// vaut un message superflu sur un double-clic qu'un bouton muet qui
|
|
// ne fait jamais rien sans explication.
|
|
ecouteVoulue = false;
|
|
notifier("Impossible de démarrer le micro (" + erreur.name + ").");
|
|
}
|
|
});
|
|
|
|
// -------- Panneau de notation (affichage seul, construit par ce script
|
|
// -------- pour être appelable depuis n'importe quel écran sans avoir à
|
|
// -------- modifier chaque page hôte — voir commentaire d'en-tête.
|
|
let panneauNotation = null;
|
|
let panneauTitre = null;
|
|
let listeNotation = null;
|
|
|
|
function construirePanneauNotation(){
|
|
if(panneauNotation) return;
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent =
|
|
"#panneau-notation-lilou{position:fixed;bottom:20px;right:20px;" +
|
|
"max-width:280px;background:#FFFFFF;border:1px solid rgba(0,0,0,0.15);" +
|
|
"border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.25);" +
|
|
"padding:12px 16px;z-index:900;font-size:13px;display:none;}" +
|
|
"#panneau-notation-lilou.visible{display:block;}" +
|
|
"#panneau-notation-lilou .titre{font-weight:700;" +
|
|
"color:var(--rouge-micro,#D9364B);margin-bottom:8px;}" +
|
|
"#panneau-notation-lilou .lignes{list-style:none;margin:0;padding:0;}" +
|
|
"#panneau-notation-lilou .lignes li{padding:2px 0 2px 14px;color:#2a2a2a;}";
|
|
document.head.appendChild(style);
|
|
|
|
panneauNotation = document.createElement("div");
|
|
panneauNotation.id = "panneau-notation-lilou";
|
|
panneauNotation.innerHTML =
|
|
'<div class="titre"></div><ul class="lignes"></ul>';
|
|
document.body.appendChild(panneauNotation);
|
|
panneauTitre = panneauNotation.querySelector(".titre");
|
|
listeNotation = panneauNotation.querySelector(".lignes");
|
|
}
|
|
|
|
// nomAgent : titre du panneau adapté à l'agent adressé (2 septembre
|
|
// 2026, plusieurs agents désormais possibles — voir ASP_Agents). Repli
|
|
// générique "L'agent" si absent (ne devrait pas arriver, demarrerNotation
|
|
// fournit toujours un agent détecté) — plus de nom d'agent précis figé en
|
|
// dur ici depuis le retrait de Lilou de ASP_Agents le 2 septembre 2026.
|
|
function ouvrirPanneauNotation(nomAgent){
|
|
construirePanneauNotation();
|
|
panneauTitre.textContent = (nomAgent || "L'agent") + " vous écoute…";
|
|
listeNotation.innerHTML = "";
|
|
panneauNotation.classList.add("visible");
|
|
}
|
|
|
|
function afficherLigneDansPanneau(ligne){
|
|
if(!listeNotation) return;
|
|
const li = document.createElement("li");
|
|
li.textContent = ligne;
|
|
listeNotation.appendChild(li);
|
|
}
|
|
|
|
function fermerPanneauNotation(){
|
|
if(panneauNotation) panneauNotation.classList.remove("visible");
|
|
}
|
|
|
|
// -------- État "notation" --------
|
|
let modeNotation = false;
|
|
let bufferNotation = [];
|
|
|
|
// Fenêtre de tolérance après un "Lilou" entendu seul (retour de Jean-Marc,
|
|
// 2 septembre 2026 : "ajoute une tâche" arrivé sans "Lilou" en tête dans
|
|
// la console). En écoute continue, le moteur découpe parfois la phrase
|
|
// dite d'une traite ("Lilou, ajoute une tâche") en deux résultats séparés
|
|
// dès qu'il y a la moindre pause après la virgule — le second morceau
|
|
// n'a alors plus "Lilou" en tête et était jusqu'ici ignoré en silence.
|
|
// Si un résultat ne contient QUE le mot déclencheur (rien d'exploitable
|
|
// derrière), on ouvre une petite fenêtre pendant laquelle le résultat
|
|
// suivant est accepté même sans "Lilou", comme s'il l'avait précédé.
|
|
let reveilEnAttente = false;
|
|
let expirationReveil = 0;
|
|
const DUREE_REVEIL_MS = 6000;
|
|
// agentReveil : agent dont le nom seul vient d'être entendu, mémorisé
|
|
// pour attribuer correctement le résultat suivant à l'intérieur de la
|
|
// fenêtre ci-dessus (2 septembre 2026 — généralisation multi-agents de ce
|
|
// mécanisme, qui ne connaissait que Lilou jusqu'ici).
|
|
let agentReveil = null;
|
|
|
|
// Phrases d'ouverture : testées sur le texte après retrait du nom de
|
|
// l'agent (indépendantes de l'agent adressé).
|
|
const PHRASES_OUVERTURE = [/tâche/i, /truc à faire/i, /note.*todolist/i];
|
|
// Phrases de clôture / annulation : recherchées n'importe où dans la
|
|
// phrase entendue, pas seulement en tête (ajusté le 1er septembre 2026
|
|
// suite au retour de Jean-Marc : dire "..., ensuite" juste avant "c'est
|
|
// tout Lilou" pour clore sur du contenu tout juste dicté n'est pas
|
|
// naturel à l'oral — "date de réalisation le 12/09 c'est tout Lilou" doit
|
|
// fonctionner dit d'une traite). Ce qui précède la phrase repérée dans la
|
|
// même phrase entendue est conservé comme dernière ligne de contenu avant
|
|
// de clore — voir traiterLigneNotation. Construites par agent ci-dessous
|
|
// (2 septembre 2026, ASP_Agents) plutôt qu'une seule fois pour "Lilou".
|
|
|
|
// -------- Agents (ASP_Agents) — assistants nommés auxquels une consigne
|
|
// -------- vocale peut être adressée (2 septembre 2026, demande de
|
|
// -------- Jean-Marc, voir journal section 27 et réflexions stratégiques).
|
|
// -------- Liste récupérée depuis le serveur au chargement plutôt que
|
|
// -------- codée en dur, pour qu'ajouter/retirer un agent dans ASP_Agents
|
|
// -------- n'exige pas de modifier ce fichier.
|
|
let agentsDisponibles = [];
|
|
let agentsPrets = false;
|
|
|
|
// Échappe un nom d'agent pour un usage sûr en expression régulière (au
|
|
// cas où un nom contiendrait un jour un caractère spécial).
|
|
function echapperRegex(texte){
|
|
return texte.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// Construit les motifs de reconnaissance pour un agent donné, à partir de
|
|
// son nom et de ses variantes connues (colonne AGN_Variantes — voir
|
|
// commentaire d'en-tête, "Variantes de prononciation"). variantesBrutes
|
|
// est la chaîne brute reçue de agents.php ("Milou,Loulou"), vide ou
|
|
// absente pour un agent sans variante connue pour l'instant.
|
|
function construireAgent(nom, variantesBrutes){
|
|
const variantes = (variantesBrutes || "")
|
|
.split(",")
|
|
.map(function(v){ return v.trim(); })
|
|
.filter(function(v){ return v !== ""; });
|
|
const formes = [nom].concat(variantes).map(echapperRegex);
|
|
const motNom = formes.length > 1 ? "(?:" + formes.join("|") + ")" : formes[0];
|
|
|
|
// Seul cas qui reste codé en dur (voir commentaire d'en-tête) :
|
|
// l'homophone "les loups" pour la clôture/annulation de Lilou, qui
|
|
// porte sur la phrase entière et pas sur le seul prénom.
|
|
let motifsClotureSupp = [];
|
|
let motifsAnnulationSupp = [];
|
|
if(/^lilou$/i.test(nom)){
|
|
motifsClotureSupp = [/c'?est tou[st] les loups/i];
|
|
motifsAnnulationSupp = [/les loups\s+annule/i];
|
|
}
|
|
|
|
return {
|
|
nom: nom,
|
|
motifOuverture: new RegExp("^" + motNom + "\\s*[,:]?\\s*", "i"),
|
|
motifsCloture: [new RegExp("c'?est tout " + motNom, "i")].concat(motifsClotureSupp),
|
|
motifsAnnulation: [new RegExp(motNom + "\\s+annule", "i")].concat(motifsAnnulationSupp)
|
|
};
|
|
}
|
|
|
|
// Repère, en tête du texte donné, le nom d'un agent connu — renvoie
|
|
// l'agent et le texte restant (commande) une fois le nom retiré, ou null
|
|
// si aucun agent connu n'est reconnu en tête de phrase.
|
|
function trouverAgentDeclencheur(texte){
|
|
for(let i = 0; i < agentsDisponibles.length; i++){
|
|
const agent = agentsDisponibles[i];
|
|
if(agent.motifOuverture.test(texte)){
|
|
return {agent: agent, commande: texte.replace(agent.motifOuverture, "").trim()};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function chargerAgents(){
|
|
fetch("agents.php")
|
|
.then(function(reponse){
|
|
if(!reponse.ok) throw new Error("HTTP " + reponse.status);
|
|
return reponse.json();
|
|
})
|
|
.then(function(donnees){
|
|
const liste = (donnees && donnees.agents) || [];
|
|
if(liste.length === 0) throw new Error("liste vide");
|
|
agentsDisponibles = liste.map(function(a){ return construireAgent(a.AGN_Nom, a.AGN_Variantes); });
|
|
agentsPrets = true;
|
|
})
|
|
.catch(function(){
|
|
// Plus de repli sur un nom d'agent figé en dur ici (2 septembre
|
|
// 2026, décision de Jean-Marc après le retrait de Lilou de
|
|
// ASP_Agents — un agent codé en dur ici referait exactement le
|
|
// problème qu'on vient de corriger côté agent_notes.php). Si
|
|
// agents.php est injoignable, la liste reste vide : aucune phrase
|
|
// ne déclenche de notation tant que la page n'est pas rechargée —
|
|
// même comportement que du bruit de fond ignoré, plutôt que de
|
|
// prétendre qu'un agent précis est disponible alors que la vraie
|
|
// liste n'a pas pu être chargée. Seule différence avec le bruit de
|
|
// fond ordinaire : un avertissement explicite, pour que ça ne
|
|
// passe pas inaperçu.
|
|
agentsDisponibles = [];
|
|
agentsPrets = true;
|
|
notifier("Liste des agents indisponible — aucun agent ne sera reconnu tant que la page n'est pas rechargée.");
|
|
});
|
|
}
|
|
chargerAgents();
|
|
|
|
// Marqueur oral de fin de ligne ("..., ensuite") retiré du texte conservé.
|
|
const MARQUEUR_FIN_LIGNE = /\s*,?\s*ensuite\s*[.,!]?\s*$/i;
|
|
|
|
// Repère la première occurrence (n'importe où dans le texte) d'une phrase
|
|
// de la liste donnée, et renvoie sa position et sa longueur — ou null si
|
|
// aucune ne correspond.
|
|
function trouverPhraseDeControle(texte, motifs){
|
|
for(let i = 0; i < motifs.length; i++){
|
|
const correspondance = texte.match(motifs[i]);
|
|
if(correspondance) return {index: correspondance.index, longueur: correspondance[0].length};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Nettoie une ligne de contenu : retire le marqueur oral "ensuite" en fin
|
|
// de ligne s'il est présent, puis toute ponctuation de fin isolée
|
|
// restante (cas d'une ligne coupée juste avant une phrase de clôture/
|
|
// annulation repérée en cours de phrase, sans "ensuite" prononcé).
|
|
function nettoyerLigne(texte){
|
|
return texte.replace(MARQUEUR_FIN_LIGNE, "").replace(/[\s,;:]+$/, "").trim();
|
|
}
|
|
|
|
// Même nettoyage, côté début de ligne cette fois : retire la ponctuation
|
|
// de liaison isolée qui suit souvent une phrase de contrôle repérée en
|
|
// milieu de phrase ("... tâche : penser à ..." → ": " à retirer avant de
|
|
// garder "penser à ...").
|
|
function nettoyerDebutLigne(texte){
|
|
return texte.replace(/^[\s:,;-]+/, "").trim();
|
|
}
|
|
|
|
// Empile une ligne dans le tampon de notation avec son type ("ouverture",
|
|
// "contenu", "cloture" ou "annulation" — demande de Jean-Marc, 2
|
|
// septembre 2026, pour qu'un futur robot sache à quel assistant/action
|
|
// router le lot sans avoir à ré-analyser le texte). Seul le contenu
|
|
// s'affiche dans le panneau visuel — les marqueurs d'ouverture/clôture
|
|
// sont enregistrés mais ne servent à rien à afficher à l'utilisateur.
|
|
function ajouterAuTampon(texte, type){
|
|
if(!texte) return;
|
|
bufferNotation.push({texte: texte, type: type});
|
|
if(type === "contenu"){
|
|
afficherLigneDansPanneau(texte);
|
|
}
|
|
}
|
|
|
|
function envoyerNotation(lignes){
|
|
// Renommé le 2 septembre 2026 (ex-lilou_notes.php) — plus de raison de
|
|
// porter le nom d'un seul agent, voir agent_notes.php. "agent" : nom
|
|
// de l'assistant adressé pour tout ce lot (agentCourant, capturé à
|
|
// l'ouverture), copié dans CVX_AgentTraitant pour chaque ligne. Chaîne
|
|
// vide si agentCourant est absent (ne devrait pas arriver) plutôt qu'un
|
|
// nom d'agent précis figé en dur ici : agent_notes.php sait désormais
|
|
// résoudre lui-même l'agent par défaut depuis ASP_Agents.AGN_Defaut
|
|
// (voir ce fichier, 2 septembre 2026, suite du retrait de Lilou).
|
|
fetch("agent_notes.php", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({agent: agentCourant ? agentCourant.nom : "", lignes: lignes})
|
|
}).then(function(reponse){
|
|
if(!reponse.ok) throw new Error("HTTP " + reponse.status);
|
|
// Message rendu explicite à la demande de Jean-Marc (2 septembre
|
|
// 2026), pour ne laisser aucun doute sur le fait que l'enregistrement
|
|
// a bien eu lieu, et où : la table ASP_ConsignesVoix, qualifiée de
|
|
// "provisoire" (nom générique, avant les règles d'interprétation de
|
|
// l'étape 2).
|
|
notifier("Consigne enregistrée dans la table provisoire");
|
|
}).catch(function(){
|
|
notifier("Erreur : la note n'a pas pu être enregistrée.");
|
|
});
|
|
}
|
|
|
|
// agentCourant : agent adressé pour la notation en cours (objet construit
|
|
// par construireAgent) — capturé à l'ouverture, sert aussi bien pour la
|
|
// reconnaissance de la clôture/annulation propre à cet agent que pour
|
|
// l'envoi final (2 septembre 2026, ASP_Agents).
|
|
let agentCourant = null;
|
|
|
|
function demarrerNotation(agent){
|
|
modeNotation = true;
|
|
bufferNotation = [];
|
|
agentCourant = agent;
|
|
ouvrirPanneauNotation(agent.nom);
|
|
notifier(agent.nom + ' note votre demande — dites "c\'est tout ' + agent.nom + '" pour terminer, ou "' + agent.nom + ' annule" pour annuler.');
|
|
}
|
|
|
|
function terminerNotation(){
|
|
modeNotation = false;
|
|
fermerPanneauNotation();
|
|
if(bufferNotation.length > 0){
|
|
envoyerNotation(bufferNotation);
|
|
}
|
|
bufferNotation = [];
|
|
}
|
|
|
|
function traiterLigneNotation(texte){
|
|
// Clôture/annulation propres à l'agent en cours (2 septembre 2026) —
|
|
// "c'est tout Viktor" ne referme pas une notation ouverte avec Lilou,
|
|
// et réciproquement.
|
|
const motifsCloture = agentCourant ? agentCourant.motifsCloture : [];
|
|
const motifsAnnulation = agentCourant ? agentCourant.motifsAnnulation : [];
|
|
const cloture = trouverPhraseDeControle(texte, motifsCloture);
|
|
const annulation = cloture ? null : trouverPhraseDeControle(texte, motifsAnnulation);
|
|
const repere = cloture || annulation;
|
|
if(repere){
|
|
// Tout ce qui précède la phrase de clôture/annulation dans cette même
|
|
// phrase entendue est la dernière ligne de contenu ("date de
|
|
// réalisation le 12/09 c'est tout Lilou" dit d'une traite) : on la
|
|
// capture avant de clore.
|
|
const avant = nettoyerLigne(texte.slice(0, repere.index));
|
|
ajouterAuTampon(avant, "contenu");
|
|
|
|
// La phrase de contrôle elle-même est maintenant enregistrée aussi
|
|
// (demande de Jean-Marc, 2 septembre 2026), avec son type — cloture
|
|
// ou annulation distinguées cette fois (alors que le traitement du
|
|
// contenu, lui, reste identique dans les deux cas, comme décidé le
|
|
// 1er septembre) : un futur robot doit savoir si le lot est à
|
|
// exécuter ou à écarter sans avoir à ré-interpréter le texte brut.
|
|
const phraseControle = texte.slice(repere.index, repere.index + repere.longueur).trim();
|
|
ajouterAuTampon(phraseControle, cloture ? "cloture" : "annulation");
|
|
|
|
// Symétriquement, ce qui suit la phrase de clôture/annulation dans la
|
|
// même phrase entendue ("c'est tout Lilou, et puis aussi ...") était
|
|
// jusqu'ici perdu silencieusement. Philosophie du projet : on
|
|
// n'interprète rien, on note tout ce qui a été dit (retour de
|
|
// Jean-Marc, 2 septembre 2026 — voir aussi le symétrique côté
|
|
// ouverture ci-dessous).
|
|
const apres = nettoyerLigne(nettoyerDebutLigne(texte.slice(repere.index + repere.longueur)));
|
|
ajouterAuTampon(apres, "contenu");
|
|
|
|
terminerNotation();
|
|
return;
|
|
}
|
|
ajouterAuTampon(nettoyerLigne(texte), "contenu");
|
|
}
|
|
|
|
reconnaissance.addEventListener("start", passerEnEcoute);
|
|
|
|
reconnaissance.addEventListener("end", function(){
|
|
if(ecouteVoulue && !erreurBloquante){
|
|
// Le moteur s'est arrêté tout seul (silence prolongé, limite interne
|
|
// du navigateur...) alors que l'utilisateur n'a rien demandé de tel
|
|
// : on relance aussitôt, sans que ça se voie côté bouton.
|
|
try{
|
|
reconnaissance.start();
|
|
}catch(erreur){
|
|
revenirAuRepos();
|
|
notifier("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
|
|
}
|
|
return;
|
|
}
|
|
revenirAuRepos();
|
|
});
|
|
|
|
// Détection du mot déclencheur (2 septembre 2026, généralisation
|
|
// multi-agents) : plus un seul motifDeclencheur codé en dur pour Lilou,
|
|
// mais trouverAgentDeclencheur(texte) qui teste chaque agent connu
|
|
// (agentsDisponibles, chargé depuis ASP_Agents — voir plus haut) et
|
|
// renvoie celui reconnu en tête de phrase, le cas échéant.
|
|
|
|
reconnaissance.addEventListener("result", function(evenement){
|
|
const dernier = evenement.results[evenement.results.length - 1];
|
|
const texte = dernier[0].transcript.trim();
|
|
|
|
if(modeNotation){
|
|
// En notation, plus besoin du nom de l'agent en tête sur chaque
|
|
// ligne — on ne teste que la clôture/annulation propre à
|
|
// agentCourant, tout le reste est du contenu. Une ligne captée ici
|
|
// est une vraie interaction : le délai d'inactivité est repoussé.
|
|
traiterLigneNotation(texte);
|
|
reinitialiserMinuteurInactivite();
|
|
return;
|
|
}
|
|
|
|
let agent;
|
|
let commande;
|
|
const declenchement = trouverAgentDeclencheur(texte);
|
|
if(declenchement){
|
|
agent = declenchement.agent;
|
|
commande = declenchement.commande;
|
|
} else if(reveilEnAttente && agentReveil && Date.now() <= expirationReveil){
|
|
// Un nom d'agent venait d'être entendu seul : on accepte ce
|
|
// résultat-ci comme s'il avait été précédé du nom, pour le même
|
|
// agent (voir commentaire sur reveilEnAttente/agentReveil plus haut).
|
|
agent = agentReveil;
|
|
commande = texte;
|
|
} else {
|
|
// Pas adressé à un agent connu (conversation de fond, etc.) : ignoré
|
|
// en silence, sans notification, et SANS repousser le délai
|
|
// d'inactivité — c'est justement ce que ce délai doit détecter (voir
|
|
// couperPourInactivite plus haut). Laissé en console pour le debug.
|
|
console.log('[micro.js] ignoré (aucun agent reconnu en tête) :', texte);
|
|
return;
|
|
}
|
|
reveilEnAttente = false;
|
|
agentReveil = null;
|
|
reinitialiserMinuteurInactivite();
|
|
|
|
if(!commande){
|
|
// Nom d'agent entendu seul, rien d'exploitable derrière dans ce même
|
|
// résultat — probablement coupé par le moteur juste après la
|
|
// virgule. On laisse une petite fenêtre pour que la suite, dite
|
|
// juste après, soit acceptée même sans le nom en tête, et on
|
|
// mémorise l'agent concerné (agentReveil) pour l'attribuer
|
|
// correctement.
|
|
reveilEnAttente = true;
|
|
agentReveil = agent;
|
|
expirationReveil = Date.now() + DUREE_REVEIL_MS;
|
|
notifier(agent.nom + " vous écoute.");
|
|
return;
|
|
}
|
|
|
|
const repereOuverture = trouverPhraseDeControle(commande, PHRASES_OUVERTURE);
|
|
if(repereOuverture){
|
|
demarrerNotation(agent);
|
|
// La phrase d'ouverture elle-même est enregistrée (type "ouverture",
|
|
// demande de Jean-Marc le 2 septembre 2026) — texte complet jusqu'à
|
|
// la fin de la phrase repérée ("ajoute une tâche", "un truc à
|
|
// faire"...), pour qu'un futur robot puisse s'en servir pour choisir
|
|
// le bon assistant, sans que Claude/l'agent n'ait à l'interpréter ici.
|
|
const phraseOuverture = commande.slice(0, repereOuverture.index + repereOuverture.longueur).trim();
|
|
ajouterAuTampon(phraseOuverture, "ouverture");
|
|
|
|
// Contenu éventuellement dicté dans la même phrase que l'ouverture
|
|
// ("Lilou, ajoute une tâche, penser à ..." dit d'une traite, sans
|
|
// pause) : jusqu'ici perdu silencieusement — demarrerNotation() ne
|
|
// fait que réinitialiser le tampon et ouvrir le panneau, rien
|
|
// n'était capturé pour ce premier morceau. Corrigé le 2 septembre
|
|
// 2026 (retour de Jean-Marc : "texte de départ... pas noté, qui
|
|
// pourtant s'était affiché") en reprenant le même principe que la
|
|
// clôture juste au-dessus : tout ce qui suit la phrase d'ouverture
|
|
// repérée devient la première ligne de contenu.
|
|
const apres = nettoyerLigne(nettoyerDebutLigne(
|
|
commande.slice(repereOuverture.index + repereOuverture.longueur)
|
|
));
|
|
ajouterAuTampon(apres, "contenu");
|
|
return;
|
|
}
|
|
|
|
// Étape 1 (hors notation) : simple retour visuel du texte reconnu
|
|
// après le nom de l'agent, pour valider le circuit — aucune autre
|
|
// commande interprétée à ce stade.
|
|
notifier(agent.nom + ' a entendu : "' + commande + '"');
|
|
});
|
|
|
|
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;
|
|
notifier(messagesBloquants[evenement.error]);
|
|
return;
|
|
}
|
|
// Erreurs considérées transitoires (rien entendu, réseau...) : pas de
|
|
// erreurBloquante, l'événement "end" qui suit relancera l'écoute tant
|
|
// que ecouteVoulue reste vrai.
|
|
const messagesTransitoires = {
|
|
"no-speech": "Rien entendu, le micro reste ouvert.",
|
|
"network": "Problème réseau pendant la reconnaissance, nouvelle tentative..."
|
|
};
|
|
notifier(messagesTransitoires[evenement.error] || ("Erreur micro (" + evenement.error + ")."));
|
|
});
|
|
})();
|