Compare commits

...
22 Commits
Author SHA1 Message Date
Jean-Marc dd2b7a6872 V1.23.33 Fin de la récupération des entreprises 2026-09-20 08:49:20 +00:00
Jean-Marc f0711f49ac V1.23.29 Mise en place des feuilles de route 2026-09-17 08:53:43 +00:00
Jean-Marc 2825430bf4 V1.23.28 Mise à jour de la BDD des entreprises et stratégies 2026-09-15 21:45:02 +00:00
Jean-Marc e2de83ad41 V1.23.25 Mise à jour de la BDD des entreprises avec 22 et 35 2026-09-12 20:33:47 +00:00
Jean-Marc 787392f725 V1.23.25 Constitution d'une BDD mutualisée des entreprises et alimentation avec 56, 29 2026-09-12 14:46:10 +00:00
Jean-Marc bac56b85a0 V1.23.24 Externalisation des prompts et évolutions des collaborateurs 2026-09-11 20:25:48 +00:00
Jean-Marc 05df1c5a28 V1.22.23 Passage à Claude Code et création du BackOffice 2026-09-11 13:38:34 +00:00
Jean-Marc a9af2a5213 V1.19.21 Réflexions stratégiques et gestion de la personnalité des collaborateurs 2026-09-10 13:49:26 +00:00
Jean-Marc 0dee1c7ebd V1.18.19 Mise en place des collaborateurs 2026-09-10 06:24:16 +00:00
Jean-Marc 67614908c3 V1.17.18 Modifications consignes correction bugs 2026-09-08 15:28:26 +00:00
Jean-Marc 9d537a9b36 V1.16.18 Modification des écrans 2026-09-08 13:10:12 +00:00
Jean-Marc 44be17d99b V1.15.18 Réflexions sur les agents 2026-09-08 03:19:19 +00:00
Jean-Marc 0f7437fa77 V1.15.17 Evolutions des consignes 2026-09-08 01:29:41 +00:00
Jean-Marc d287607e45 V1.14.15 Mise en plce des consignes et premiers tests 2026-09-07 06:16:51 +00:00
Jean-Marc 3d56de8bfa V1.13.14 Mise en place d'une nouvelle numérotation et évolutions stratégiques 2026-09-06 16:42:23 +00:00
Jean-Marc e6dfb15ea6 V1.012 Avec évolutions des réflexions stratégiques (pas de modif de code) 2026-09-06 15:09:37 +00:00
Jean-Marc ba8903d87b V1.012 Début du traitement par IA des retranscriptions des dictées 2026-09-05 05:44:30 +00:00
Jean-Marc 2a7d257563 V1.011 Tests de dictées vocales 2026-09-04 06:39:55 +00:00
Jean-Marc fc5ea2d5ba V1.010 préparation de la création d'une banque de dictées 2026-09-03 20:36:51 +00:00
Jean-Marc 32e36387dd V1.009 Homogénéisation des écrans (début) 2026-09-03 15:21:59 +00:00
Jean-Marc 95c8411a35 V1.008 rectifiée Evolution des paramétrages de l'écran de fond 2026-09-03 08:26:01 +00:00
Jean-Marc d5be94c4d8 V1.008 Evolution des paramétrages de l'écran de fond 2026-09-03 08:17:15 +00:00
82 changed files with 11398 additions and 422 deletions
+8
View File
@@ -0,0 +1,8 @@
# Répertoire d'échange temporaire de fichiers avec Claude — jamais versionné.
claude_code/
# Cache bytecode Python, regenere automatiquement.
scripts/__pycache__/
# Identifiants (cles API, mots de passe) -- jamais versionnes.
scripts/.env
+32
View File
@@ -0,0 +1,32 @@
# ASPIC — .htaccess racine de l'application
# ---------------------------------------------------------------------------
# Ajouté le 3 septembre 2026 (voir journal) : évite d'avoir à passer par une
# navigation privée pour voir une nouvelle version après un dépôt de fichiers
# — le navigateur devait jusqu'ici servir une copie en cache de la page tant
# qu'elle restait dans sa fenêtre de fraîcheur heuristique, sans même
# redemander au serveur.
#
# "no-cache" (et non "no-store") : le navigateur peut toujours garder une
# copie, mais doit obligatoirement revalider auprès du serveur avant de
# l'utiliser (requête conditionnelle, via ETag/Last-Modified, gérée
# automatiquement par Apache) — la nouvelle version est donc servie dès
# qu'elle est déposée, sans re-téléchargement complet à chaque fois.
#
# Limité à .html/.js/.css : les fichiers .php sont dynamiques et déjà non
# mis en cache par défaut ; les images/polices (.jpg/.png/.woff2/.ttf) sont
# volontairement laissées en cache normal, elles changent rarement.
#
# À déposer À LA RACINE de l'application (même dossier que identification.html),
# sous le nom exact ".htaccess" (renommer ce fichier .txt après dépôt — voir
# note de la session au sujet des fichiers technique non nommés .htaccess
# envoyés depuis la conversation).
#
# Nécessite le module Apache mod_headers activé sur le VPS. S'il ne semble
# pas produire d'effet après dépôt, c'est le premier point à vérifier
# (`a2enmod headers` puis redémarrage d'Apache, si accès SSH).
<IfModule mod_headers.c>
<FilesMatch "\.(html|js|css)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
</IfModule>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

+3 -2
View File
@@ -105,12 +105,13 @@ if ($agent === '') {
// marqué par défaut (ne devrait pas arriver en usage normal, simple
// filet de sécurité pour ne jamais laisser CVX_AgentTraitant vide).
try {
// AGN_Nom renommée AGN_Prenom en base (7 septembre 2026, nuit).
$stmtDefaut = $pdo->query(
"SELECT AGN_Nom FROM ASP_Agents WHERE AGN_Defaut = 'O' ORDER BY AGN_Incr ASC LIMIT 1"
"SELECT AGN_Prenom FROM ASP_Agents WHERE AGN_Defaut = 'O' ORDER BY AGN_Incr ASC LIMIT 1"
);
$agent = $stmtDefaut->fetchColumn();
if ($agent === false || $agent === '') {
$stmtPremier = $pdo->query('SELECT AGN_Nom FROM ASP_Agents ORDER BY AGN_Incr ASC LIMIT 1');
$stmtPremier = $pdo->query('SELECT AGN_Prenom FROM ASP_Agents ORDER BY AGN_Incr ASC LIMIT 1');
$agent = $stmtPremier->fetchColumn();
}
} catch (PDOException $e) {
+23 -1
View File
@@ -45,7 +45,29 @@ try {
// Loulou"), utilisées par micro.js pour tolérer les confusions
// récurrentes de la reconnaissance vocale sans avoir à modifier le
// code à chaque fois — colonne éditable directement par Jean-Marc.
$stmt = $pdo->query('SELECT AGN_Incr, AGN_Nom, AGN_Fonction, AGN_Variantes FROM ASP_Agents ORDER BY AGN_Incr ASC');
//
// AGN_Nom renommée AGN_Prenom (7 septembre 2026, nuit — Jean-Marc,
// homogénéisation avec USR_Prenom) : répercuté ici et dans tous les
// consommateurs de ce endpoint (micro.js, contact_ia_panel.js,
// contact_ia_interprete.php) — la clé JSON change de nom avec la colonne,
// pas d'alias de compatibilité gardé, pour ne pas laisser deux noms
// cohabiter pour la même chose.
//
// AGN_Photo (9 septembre 2026, écran assistants) : nom de fichier de la
// photo de l'agent (ex. "assistant_matilda.jpg"), colonne éditée à la
// main par Jean-Marc — même principe que AGN_Variantes. Valeur
// vide/absente tolérée côté écran (repli visuel, voir ecran_fond.html) :
// un agent sans photo reste affiché.
//
// AGN_Couleur (9 septembre 2026) : couleur pastel affectée à l'agent (ex.
// "#A6D9F2"), pas encore utilisée par aucun écran à ce stade — récupérée
// ici par anticipation (même table, même endpoint), voir réflexions
// stratégiques pour son usage prévu (post-its par assistant notamment).
//
// ORDER BY AGN_OrdreAffichage (9 septembre 2026) remplace AGN_Incr :
// Jean-Marc pilote désormais l'ordre d'affichage indépendamment de
// l'ordre de création des agents (ex. écran assistants).
$stmt = $pdo->query('SELECT AGN_Incr, AGN_Prenom, AGN_Fonction, AGN_Variantes, AGN_Photo, AGN_Couleur FROM ASP_Agents ORDER BY AGN_OrdreAffichage ASC');
echo json_encode(['agents' => $stmt->fetchAll()]);
} catch (PDOException $e) {
http_response_code(500);
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* ASPIC — agents_competences.php
*
* Endpoint du BackOffice (11 septembre 2026) — attribution des compétences
* aux collaborateurs (`ASP_AgentsCompetences`). Séparé de `competences.php`
* (le catalogue lui-même) : ici on ne fait que relier un `AGN_Incr` à un
* `CMP_Code` déjà existant, jamais créer une compétence à la volée.
*
* GET : liste toutes les attributions, avec le prénom du collaborateur et
* le libellé de la compétence (jointure, pour affichage direct).
* POST { "AGC_AgentIncr": 13, "AGC_CompetenceCode": "SITE_WEB" } : attribue.
* DELETE { "AGC_AgentIncr": 13, "AGC_CompetenceCode": "SITE_WEB" } : retire.
*
* À chaque attribution/retrait réussi, `ASP_Agents.AGN_Competences` (champ
* texte dénormalisé) est intégralement recalculé et écrasé à partir de
* l'état courant de `ASP_AgentsCompetences`/`ASP_Competences` — jamais un
* ajout incrémental (contrairement à AGN_Experience) puisqu'il doit
* refléter l'attribution actuelle, pas un historique cumulatif.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
/**
* Recalcule et écrase AGN_Competences pour un agent à partir de l'état
* courant de la table de liaison — lu ensuite par mission_agent.php et le
* dialogue de test, au même titre qu'AGN_Identite/AGN_Experience.
*/
function recalculerCompetencesAgent(PDO $pdo, int $agentIncr): void
{
$stmt = $pdo->prepare(
'SELECT c.CMP_Libelle, c.CMP_Description
FROM ASP_AgentsCompetences ac
JOIN ASP_Competences c ON c.CMP_Code = ac.AGC_CompetenceCode
WHERE ac.AGC_AgentIncr = ?
ORDER BY c.CMP_Libelle'
);
$stmt->execute([$agentIncr]);
$texte = implode("\n", array_map(
fn(array $c) => $c['CMP_Description']
? "- {$c['CMP_Libelle']} : {$c['CMP_Description']}"
: "- {$c['CMP_Libelle']}",
$stmt->fetchAll()
));
$pdo->prepare('UPDATE ASP_Agents SET AGN_Competences = ? WHERE AGN_Incr = ?')
->execute([$texte !== '' ? $texte : null, $agentIncr]);
}
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$lignes = $pdo->query(
'SELECT ac.AGC_Incr, ac.AGC_AgentIncr, ac.AGC_CompetenceCode, ac.AGC_DateAttribution,
a.AGN_Prenom, c.CMP_Libelle
FROM ASP_AgentsCompetences ac
JOIN ASP_Agents a ON a.AGN_Incr = ac.AGC_AgentIncr
JOIN ASP_Competences c ON c.CMP_Code = ac.AGC_CompetenceCode
ORDER BY a.AGN_Prenom, c.CMP_Libelle'
)->fetchAll();
echo json_encode(['attributions' => $lignes]);
exit;
}
if ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$agentIncr = (int) ($data['AGC_AgentIncr'] ?? 0);
$competenceCode = trim((string) ($data['AGC_CompetenceCode'] ?? ''));
if ($agentIncr <= 0 || $competenceCode === '') {
http_response_code(400);
echo json_encode(['error' => 'AGC_AgentIncr et AGC_CompetenceCode sont obligatoires.']);
exit;
}
try {
$verifAgent = $pdo->prepare('SELECT 1 FROM ASP_Agents WHERE AGN_Incr = ?');
$verifAgent->execute([$agentIncr]);
if (!$verifAgent->fetch()) {
http_response_code(404);
echo json_encode(['error' => 'Collaborateur introuvable']);
exit;
}
$verifComp = $pdo->prepare('SELECT 1 FROM ASP_Competences WHERE CMP_Code = ?');
$verifComp->execute([$competenceCode]);
if (!$verifComp->fetch()) {
http_response_code(404);
echo json_encode(['error' => 'Compétence introuvable']);
exit;
}
$stmt = $pdo->prepare('SELECT 1 FROM ASP_AgentsCompetences WHERE AGC_AgentIncr = ? AND AGC_CompetenceCode = ?');
$stmt->execute([$agentIncr, $competenceCode]);
if ($stmt->fetch()) {
http_response_code(409);
echo json_encode(['error' => 'Ce collaborateur a déjà cette compétence.']);
exit;
}
$pdo->prepare('INSERT INTO ASP_AgentsCompetences (AGC_AgentIncr, AGC_CompetenceCode) VALUES (?, ?)')
->execute([$agentIncr, $competenceCode]);
recalculerCompetencesAgent($pdo, $agentIncr);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
if ($method === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$agentIncr = (int) ($data['AGC_AgentIncr'] ?? 0);
$competenceCode = trim((string) ($data['AGC_CompetenceCode'] ?? ''));
if ($agentIncr <= 0 || $competenceCode === '') {
http_response_code(400);
echo json_encode(['error' => 'AGC_AgentIncr et AGC_CompetenceCode sont obligatoires.']);
exit;
}
try {
$stmt = $pdo->prepare('DELETE FROM ASP_AgentsCompetences WHERE AGC_AgentIncr = ? AND AGC_CompetenceCode = ?');
$stmt->execute([$agentIncr, $competenceCode]);
recalculerCompetencesAgent($pdo, $agentIncr);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* ASPIC — appels_ia.php
*
* Endpoint du BackOffice (11 septembre 2026) — lecture du journal des
* appels réels à l'API Anthropic (ASP_AppelsIA, voir journal_ia.php).
* Construit après que Jean-Marc a constaté plus de 20€ de consommation sans
* pouvoir se l'expliquer : donne enfin une visibilité réelle, appel par
* appel, plutôt que de devoir deviner depuis la seule facture.
*
* GET : total général, total par origine, et les 50 derniers appels.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$totalGeneral = (float) $pdo->query('SELECT COALESCE(SUM(AIA_CoutEstimeUsd), 0) FROM ASP_AppelsIA')->fetchColumn();
$parOrigine = $pdo->query(
'SELECT AIA_Origine, COUNT(*) AS nb_appels, SUM(AIA_CoutEstimeUsd) AS cout_usd
FROM ASP_AppelsIA GROUP BY AIA_Origine ORDER BY cout_usd DESC'
)->fetchAll();
$derniers = $pdo->query(
'SELECT a.AIA_Origine, a.AIA_AgentIncr, ag.AGN_Prenom, a.AIA_MissionIncr, a.AIA_Modele,
a.AIA_TokensEntreeFrais, a.AIA_TokensEntreeCache, a.AIA_TokensEcritureCache, a.AIA_TokensSortie,
a.AIA_CoutEstimeUsd, a.AIA_Date
FROM ASP_AppelsIA a
LEFT JOIN ASP_Agents ag ON ag.AGN_Incr = a.AIA_AgentIncr
ORDER BY a.AIA_Date DESC LIMIT 50'
)->fetchAll();
echo json_encode([
'total_usd' => $totalGeneral,
'par_origine' => $parOrigine,
'derniers' => $derniers,
]);
+24 -4
View File
@@ -72,7 +72,7 @@ try {
// place sur MIRADOR (comme sur les autres bases ASPIC) est nativement
// insensible à la casse et aux accents, pas besoin d'UPPER() ici.
$stmt = $pdoRouter->prepare(
'SELECT MIR_Code, MIR_MDPHash, MIR_ServeurHost, MIR_ServeurPort, MIR_BaseNom
'SELECT MIR_Code, MIR_MDPHash, MIR_ServeurHost, MIR_ServeurPort, MIR_BaseNom, MIR_BO
FROM MIR_Users WHERE MIR_Email = ? AND MIR_Actif = 1'
);
$stmt->execute([$identifiant]);
@@ -102,6 +102,12 @@ $_SESSION['USR_Code'] = $routage['MIR_Code'];
$_SESSION['ASPIC_DB_HOST'] = $routage['MIR_ServeurHost'];
$_SESSION['ASPIC_DB_PORT'] = (int) $routage['MIR_ServeurPort'];
$_SESSION['ASPIC_DB_NAME'] = $routage['MIR_BaseNom'];
// Accès BackOffice (11 septembre 2026, chantier Compétences/Missions) : lu
// une seule fois ici, à l'identification contre MIRADOR — jamais réinterrogé
// ensuite. Relu par preferences.php pour piloter l'affichage du bouton "BO"
// sur le bureau, jamais transmis au front-end directement depuis ce fichier
// (aspic.php répond à identification.html, pas à ecran_fond.html).
$_SESSION['USR_BackOffice'] = ($routage['MIR_BO'] === 'O');
// Nom affiché : toujours porté par ASP_Users de la base cible (MIRADOR ne
// connaît que l'email/code/mot de passe/routage, pas le nom complet). Un
@@ -117,14 +123,28 @@ try {
exit;
}
$stmtNom = $pdoCible->prepare('SELECT USR_Nom FROM ASP_Users WHERE USR_Code = ?');
// USR_Prenom récupéré ici en même temps que USR_Nom (7 septembre 2026, nuit)
// et posé en session : sert désormais de "nom affiché" pour les réalisations
// créées par cet utilisateur (voir REA_NomAffiche, realisations.php) sans
// jamais toucher à USR_Code lui-même — Jean-Marc a explicitement écarté un
// renommage de USR_Code (trop structurant) et une jointure ASP_Users refaite
// à chaque affichage/écriture (source de complication inutile, et
// potentiellement impossible si utilisateurs et agents IA finissent un jour
// dans des bases séparées) : un seul aller-retour ici, à la connexion,
// suffit pour tout le reste de la session.
$stmtNom = $pdoCible->prepare('SELECT USR_Nom, USR_Prenom FROM ASP_Users WHERE USR_Code = ?');
$stmtNom->execute([$routage['MIR_Code']]);
$nom = $stmtNom->fetchColumn();
$ligneUsr = $stmtNom->fetch();
$nom = $ligneUsr['USR_Nom'] ?? null;
$prenom = $ligneUsr['USR_Prenom'] ?? null;
$_SESSION['USR_Prenom'] = $prenom;
echo json_encode([
'success' => true,
'user' => [
'USR_Code' => $routage['MIR_Code'],
'USR_Nom' => $nom !== false ? $nom : null,
'USR_Nom' => $nom,
'USR_Prenom' => $prenom,
],
]);
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+49
View File
@@ -0,0 +1,49 @@
/* ASPIC — backoffice.css (11 septembre 2026)
Coquille visuelle commune à toutes les pages du BackOffice — mécanique
interne jamais destinée à l'utilisateur final (identités des
collaborateurs, compétences, missions-types...). Esprit "terminal" choisi
par Jean-Marc plutôt qu'un habillage soigné, même famille que
contact_ia_test.html/mission_test.html — pas de polish graphique tant que
les fonctionnalités elles-mêmes ne sont pas stabilisées.
Layout : colonne de menu à gauche (#bo-menu), contenu à droite
(#bo-contenu), barre logo/version en bas (#bo-pied) — reprend le motif
déjà en place sur ecran_fond.html (#statut-version-bloc/#marque-aspic)
pour rester dans "l'esprit de l'application principale" (demande de
Jean-Marc), simplement ancrée en bas de page plutôt qu'en overlay sur une
photo. */
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;background:#0a0a0a;color:#ddd;
font-family:"Segoe UI","Arial",sans-serif;}
#bo-cadre{display:flex;flex-direction:column;height:100vh;}
#bo-corps{flex:1;display:flex;min-height:0;}
#bo-menu{width:220px;flex:0 0 220px;background:#111;border-right:1px solid #2a2a2a;
padding:18px 0;overflow-y:auto;}
#bo-menu .titre-bo{font-size:12px;color:#666;text-transform:uppercase;
letter-spacing:.08em;padding:0 18px 14px;border-bottom:1px solid #222;margin-bottom:10px;}
#bo-menu a{display:block;padding:10px 18px;color:#bbb;text-decoration:none;
font-size:13px;border-left:3px solid transparent;}
#bo-menu a:hover{background:#1a1a1a;color:#fff;}
#bo-menu a.actif{background:#1a1a1a;color:#bdf7bd;border-left-color:#3a6d3a;}
#bo-contenu{flex:1;min-width:0;overflow-y:auto;padding:22px 28px;}
#bo-contenu h1{font-size:16px;color:#ccc;font-weight:normal;margin:0 0 4px;}
#bo-contenu .sous-titre{font-size:12px;color:#666;margin:0 0 20px;}
#bo-pied{flex:0 0 auto;display:flex;align-items:center;justify-content:center;
gap:8px;padding:8px 0;background:#111;border-top:1px solid #222;
font-size:12px;color:#777;}
#bo-pied img{width:18px;height:18px;border-radius:3px;}
#bo-pied .A{color:#e14a5c;font-weight:700;}
#bo-pied .rest{font-weight:700;color:#aaa;}
#bo-pied #bo-version{color:#666;margin-left:4px;}
/* Terminal-esque, réutilisé sur chaque page BO pour les zones de saisie. */
textarea, input, select{font:inherit;font-size:13px;background:#000;color:#eee;
border:1px solid #444;border-radius:4px;padding:7px 9px;}
textarea{font-family:"Courier New",Consolas,monospace;}
button{font:inherit;font-size:13px;background:#1a1a1a;color:#ccc;
border:1px solid #444;border-radius:5px;padding:7px 16px;cursor:pointer;}
button.principal{background:#2a4d2a;color:#bdf7bd;border-color:#3a6d3a;}
button.danger{background:#4d2a2a;color:#f7bdbd;border-color:#6d3a3a;}
button:disabled{opacity:.4;cursor:default;}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>BackOffice</h1>
<p class="sous-titre">Mécanique interne d'ASPIC — jamais un écran utilisateur. Choisis une option à gauche.</p>
<p style="color:#666;font-size:12px;">Échap, depuis cet écran, referme le BackOffice et revient à la CRM.</p>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>initBackoffice({ cibleEchap: 'ecran_fond.html' });</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
/* ASPIC — backoffice.js (11 septembre 2026)
Mécanique commune à toutes les pages du BackOffice : menu de gauche, pied
de page (logo + version), et la règle de navigation par Échap voulue par
Jean-Marc — "chaque application intégrée au BO doit, lorsqu'on en sort,
rester dans le BO ; un simple Échap, quand aucune application n'est
ouverte, permet de sortir du BO". Concrètement : chaque page BO appelle
initBackoffice() avec sa propre cible d'Échap — 'ecran_fond.html' pour
backoffice.html elle-même (l'écran de menu, "aucune application ouverte"),
'backoffice.html' pour toutes les autres (revenir au menu, jamais sortir
directement vers la CRM). */
const BO_MENU_ITEMS = [
{ href: 'bo_collaborateurs.html', label: 'Collaborateurs' },
{ href: 'bo_competences.html', label: 'Compétences' },
{ href: 'bo_prompts.html', label: 'Prompts' },
{ href: 'bo_espaces.html', label: 'Espaces disque' },
{ href: 'bo_cout_ia.html', label: 'Coût IA' },
];
function initBackoffice(options) {
const page = (window.location.pathname.split('/').pop()) || 'backoffice.html';
const cibleEchap = options && options.cibleEchap ? options.cibleEchap : 'backoffice.html';
const menu = document.getElementById('bo-menu');
if (menu) {
const titre = document.createElement('div');
titre.className = 'titre-bo';
titre.textContent = 'BackOffice ASPIC';
menu.appendChild(titre);
BO_MENU_ITEMS.forEach(function (item) {
const lien = document.createElement('a');
lien.href = item.href;
lien.textContent = item.label;
if (item.href === page) lien.classList.add('actif');
menu.appendChild(lien);
});
}
const pied = document.getElementById('bo-pied');
if (pied) {
pied.innerHTML =
'<img src="mevelia-icone.png" alt="Mévélia Solutions">' +
'<span><span class="A">A</span><span class="rest">SPIC</span></span>' +
'<span id="bo-version"></span>';
fetch('version.php').then(function (r) { return r.json(); }).then(function (data) {
document.getElementById('bo-version').textContent = data.version || '';
}).catch(function () { /* pied de page non bloquant, jamais d'erreur affichée pour ça */ });
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
// Une application ouverte sur la page (ex. une discussion, un panneau
// d'édition) peut se réserver Échap elle-même en appelant
// e.stopPropagation() sur son propre écouteur, posé avant celui-ci —
// ce gestionnaire ne réagit donc que si personne d'autre n'a intercepté
// la touche : c'est la traduction concrète de "quand aucune application
// n'est ouverte".
window.location.href = cibleEchap;
});
}
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice — Collaborateurs</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
<style>
/* bo_collaborateurs.html (11 septembre 2026) — reprend "Construire une
identité"/"Modifier l'expérience"/"Tester l'identité", débranché
d'ecran_fond.html (écran n°2) le même soir : ces outils n'ont jamais eu
leur place dans l'écran utilisateur final, ils rejoignent enfin le
BackOffice, leur vraie maison. Logique inchangée (mêmes endpoints,
mêmes champs) — seule l'enveloppe visuelle change : plus d'écran plein
cadre par-dessus le bureau, tout vit dans #bo-contenu. */
.ligne-photos-collab{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:18px;}
.photo-collab-selection{display:flex;flex-direction:column;align-items:center;
gap:6px;cursor:pointer;background:none;border:none;padding:6px;border-radius:8px;}
.photo-collab-selection:hover{background:#1a1a1a;}
.photo-collab-selection.selectionne{background:#1a1a1a;box-shadow:0 0 0 1px #3a6d3a inset;}
.photo-collab-selection img,.photo-collab-selection .repli{
width:64px;height:64px;border-radius:8px;object-fit:cover;background:#222;}
.photo-collab-selection .repli{display:flex;align-items:center;justify-content:center;color:#888;font-weight:700;}
.photo-collab-selection .prenom-collab{font-size:12px;font-weight:700;color:#ddd;}
.photo-collab-selection .fonction-collab{font-size:10px;color:#888;}
.onglets-collab{display:flex;gap:8px;margin-bottom:14px;}
.onglets-collab button.actif{background:#233;color:#bde0f7;border-color:#3a5d7d;}
.panneau-corps{display:none;max-width:720px;}
.panneau-corps.visible{display:block;}
#texte-identite{width:100%;height:300px;resize:vertical;}
#compteur-identite{text-align:right;font-size:11px;color:#888;margin-top:4px;}
#compteur-identite.avertissement{color:#d9a84e;}
#compteur-identite.limite{color:#e14a5c;font-weight:700;}
.boutons-identite{display:flex;justify-content:flex-end;gap:10px;margin-top:12px;}
#message-identite{font-size:12px;margin-top:6px;min-height:14px;}
#message-identite.ok{color:#7be07b;}
#message-identite.ko{color:#e14a5c;}
#liste-competences-collab{max-width:720px;}
#liste-competences-collab .ligne-comp{padding:8px 0;border-bottom:1px solid #1e1e1e;font-size:13px;}
#liste-competences-collab p{color:#666;font-size:12px;}
.lien-gestion-competences{display:inline-block;margin-top:12px;font-size:12px;color:#7ab8e0;}
#chat-messages{height:320px;overflow-y:auto;background:#000;border:1px solid #333;
border-radius:6px;padding:12px;display:flex;flex-direction:column;gap:8px;max-width:720px;}
.bulle-chat{max-width:80%;padding:8px 12px;border-radius:10px;font-size:13px;
line-height:1.5;white-space:pre-wrap;}
.bulle-chat.moi{align-self:flex-end;background:#fbe14a;color:#1a1a1a;}
.bulle-chat.collab{align-self:flex-start;background:#333;color:#eee;}
.bulle-chat.systeme{align-self:center;color:#7a7;font-size:11px;font-style:italic;}
#chat-input{width:100%;max-width:720px;resize:none;margin-top:10px;}
</style>
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>Collaborateurs</h1>
<p class="sous-titre">Identité, expérience et test de dialogue — jamais consultable par un utilisateur final.</p>
<div class="ligne-photos-collab" id="ligne-photos"></div>
<div class="onglets-collab" id="onglets" style="display:none;">
<button type="button" id="onglet-identite">Identité</button>
<button type="button" id="onglet-experience">Expérience</button>
<button type="button" id="onglet-relationnel">Relationnel</button>
<button type="button" id="onglet-competences">Compétences</button>
<button type="button" id="onglet-test">Tester</button>
</div>
<div id="panneau-identite" class="panneau-corps">
<textarea id="texte-identite" spellcheck="false"></textarea>
<div id="compteur-identite"></div>
<div class="boutons-identite">
<button type="button" id="btn-abandonner">Abandonner</button>
<button type="button" id="btn-valider" class="principal">Valider</button>
</div>
<div id="message-identite"></div>
</div>
<div id="panneau-competences" class="panneau-corps">
<div id="liste-competences-collab"></div>
<a class="lien-gestion-competences" href="bo_competences.html">Gérer les compétences →</a>
</div>
<div id="panneau-test" class="panneau-corps">
<div id="chat-messages"></div>
<textarea id="chat-input" rows="2" placeholder="Écris un message…"></textarea>
<div class="boutons-identite">
<button type="button" id="btn-chat-envoyer" class="principal">Envoyer</button>
</div>
</div>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>
initBackoffice({ cibleEchap: 'backoffice.html' });
// Ébauches proposées par Claude le 10 septembre 2026 — affichées comme point
// de départ tant qu'aucune identité n'a encore été enregistrée en base.
const EBAUCHES_IDENTITE = {
'Matilda': "Matilda a fait des études de gestion avant de passer quinze ans comme assistante de direction dans une PME industrielle familiale, où elle a appris à anticiper les besoins de son dirigeant avant même qu'il les formule. Rigoureuse sur les échéances, elle relit toujours deux fois avant d'envoyer et a un sens aigu de la confidentialité — ce qui passe par elle n'en ressort jamais sans son accord. Plutôt directe à l'oral, elle préfère dire clairement ce qui ne va pas plutôt que de laisser traîner un doute.",
'Viktor': "Viktor a démarré comme commercial terrain dans la distribution avant de se spécialiser dans la vente B2B pour des PME de services. Énergique et optimiste, il croit qu'un bon relationnel finit toujours par payer — quitte à pousser un peu fort quand il sent une affaire prête à aboutir. Il déteste laisser un prospect refroidir et relance volontiers plus tôt que prévu. Un travers qu'on lui connaît : il a tendance à sous-estimer le temps qu'une négociation de prix peut prendre côté client.",
'Léon': "Léon a passé plus de vingt-cinq ans comme contrôleur de gestion dans plusieurs entreprises avant de rejoindre l'équipe sur le tard, avec la conviction qu'une affaire mal chiffrée finit toujours par coûter cher. Posé, mesuré dans ses propos, il prend le temps de vérifier avant de conclure et n'hésite jamais à freiner un enthousiasme collectif s'il juge une marge trop courte. Mieux vaut arriver avec de vrais chiffres quand on lui soumet un dossier.",
'Nikita': "Nikita vient du monde de la veille digitale et des réseaux sociaux, où elle a appris à recouper une information avant de s'y fier. Curieuse et sociable, elle aime creuser au-delà du premier résultat et préfère dire honnêtement qu'elle n'a rien trouvé plutôt que d'avancer une réponse rassurante mais bancale. Elle se méfie instinctivement d'un profil qui n'existe nulle part en ligne.",
'Sophia': "Sophia a un parcours de communication et de storytelling de marque, passée par une agence avant de rejoindre l'équipe. Créative et spontanée, elle voit dans chaque situation — bonne ou mauvaise — une histoire à raconter, et pense volontiers en termes d'image et de témoignage client plutôt qu'en termes de process. Elle a tendance à s'enthousiasmer vite pour une idée, quitte à devoir être ramenée aux contraintes concrètes du terrain."
};
const ligne = document.getElementById('ligne-photos');
const onglets = document.getElementById('onglets');
const ongletIdentite = document.getElementById('onglet-identite');
const ongletExperience = document.getElementById('onglet-experience');
const ongletRelationnel = document.getElementById('onglet-relationnel');
const ongletCompetences = document.getElementById('onglet-competences');
const ongletTest = document.getElementById('onglet-test');
const panneauIdentite = document.getElementById('panneau-identite');
const panneauCompetences = document.getElementById('panneau-competences');
const listeCompetencesCollab = document.getElementById('liste-competences-collab');
const panneauTest = document.getElementById('panneau-test');
const texteIdentite = document.getElementById('texte-identite');
const compteurIdentite = document.getElementById('compteur-identite');
const messageIdentite = document.getElementById('message-identite');
const btnValider = document.getElementById('btn-valider');
const btnAbandonner = document.getElementById('btn-abandonner');
const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const btnChatEnvoyer = document.getElementById('btn-chat-envoyer');
const TEXTE_IDENTITE_MAX_OCTETS = 16777215;
const codeurUtf8 = new TextEncoder();
let collaborateurs = [];
let collaborateurActif = null;
let champActif = null; // 'AGN_Identite' | 'AGN_Experience' | 'AGN_Relationnel'
let historiqueChatTest = [];
let premierEchangeSession = true;
function mettreAJourCompteur() {
const octets = codeurUtf8.encode(texteIdentite.value).length;
compteurIdentite.textContent = octets.toLocaleString('fr-FR') + ' / ' + TEXTE_IDENTITE_MAX_OCTETS.toLocaleString('fr-FR') + ' octets';
const depasse = octets > TEXTE_IDENTITE_MAX_OCTETS;
compteurIdentite.className = depasse ? 'limite' : (octets > TEXTE_IDENTITE_MAX_OCTETS * 0.9 ? 'avertissement' : '');
btnValider.disabled = depasse;
return depasse;
}
texteIdentite.addEventListener('input', mettreAJourCompteur);
function afficherPanneau(nom) {
panneauIdentite.classList.toggle('visible', nom === 'identite' || nom === 'experience' || nom === 'relationnel');
panneauCompetences.classList.toggle('visible', nom === 'competences');
panneauTest.classList.toggle('visible', nom === 'test');
[ongletIdentite, ongletExperience, ongletRelationnel, ongletCompetences, ongletTest].forEach(function (b) { b.classList.remove('actif'); });
if (nom === 'identite') ongletIdentite.classList.add('actif');
if (nom === 'experience') ongletExperience.classList.add('actif');
if (nom === 'relationnel') ongletRelationnel.classList.add('actif');
if (nom === 'competences') ongletCompetences.classList.add('actif');
if (nom === 'test') ongletTest.classList.add('actif');
}
async function afficherCompetencesCollab() {
listeCompetencesCollab.innerHTML = 'Chargement…';
try {
const r = await fetch('agents_competences.php');
const data = await r.json();
const lignes = (data.attributions || []).filter(function (a) { return a.AGC_AgentIncr == collaborateurActif.AGN_Incr; });
listeCompetencesCollab.innerHTML = lignes.length ? '' : '<p>Aucune compétence attribuée.</p>';
lignes.forEach(function (a) {
const div = document.createElement('div');
div.className = 'ligne-comp';
div.textContent = a.CMP_Libelle;
listeCompetencesCollab.appendChild(div);
});
} catch (e) {
listeCompetencesCollab.innerHTML = '<p>Erreur de chargement.</p>';
}
}
const PANNEAU_PAR_CHAMP = { AGN_Identite: 'identite', AGN_Experience: 'experience', AGN_Relationnel: 'relationnel' };
function chargerChamp(champ) {
champActif = champ;
texteIdentite.value = champ === 'AGN_Identite'
? (collaborateurActif.AGN_Identite || EBAUCHES_IDENTITE[collaborateurActif.AGN_Prenom] || '')
: (collaborateurActif[champ] || '');
messageIdentite.textContent = '';
messageIdentite.className = '';
mettreAJourCompteur();
afficherPanneau(PANNEAU_PAR_CHAMP[champ]);
}
function ouvrirTest() {
historiqueChatTest = [];
premierEchangeSession = true;
chatMessages.innerHTML = '';
ajouterBulle('systeme', collaborateurActif.AGN_Prenom + ' ne voit jamais ce message — session de mise au point.');
afficherPanneau('test');
}
function ajouterBulle(role, texte) {
const b = document.createElement('div');
b.className = 'bulle-chat ' + role;
// Couleur du collaborateur (AGN_Couleur, pastel) sur ses propres bulles —
// le jaune classique du post-it (#fbe14a, voir ecran_fond.html, "toujours
// pour l'humain") reste fixe en CSS pour "moi", jamais écrasé ici.
if (role === 'collab' && collaborateurActif && collaborateurActif.AGN_Couleur) {
b.style.background = collaborateurActif.AGN_Couleur;
b.style.color = '#1a1a1a';
}
b.textContent = texte;
chatMessages.appendChild(b);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
async function chargerCollaborateurs() {
ligne.textContent = 'Chargement…';
const r = await fetch('collaborateurs_identite.php');
const data = await r.json();
if (!r.ok) { ligne.textContent = data.error || 'Erreur'; return; }
collaborateurs = data.collaborateurs || [];
ligne.innerHTML = '';
collaborateurs.forEach(function (c) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'photo-collab-selection';
if (c.AGN_Photo) {
const img = document.createElement('img'); img.src = c.AGN_Photo; img.alt = c.AGN_Prenom || '';
bouton.appendChild(img);
} else {
const repli = document.createElement('div'); repli.className = 'repli'; repli.textContent = '?';
bouton.appendChild(repli);
}
const prenom = document.createElement('div'); prenom.className = 'prenom-collab'; prenom.textContent = c.AGN_Prenom || '';
bouton.appendChild(prenom);
if (c.AGN_Fonction) {
const fonction = document.createElement('div'); fonction.className = 'fonction-collab'; fonction.textContent = c.AGN_Fonction;
bouton.appendChild(fonction);
}
bouton.addEventListener('click', function () {
collaborateurActif = c;
ligne.querySelectorAll('.photo-collab-selection').forEach(function (x) { x.classList.remove('selectionne'); });
bouton.classList.add('selectionne');
onglets.style.display = 'flex';
chargerChamp('AGN_Identite');
});
ligne.appendChild(bouton);
});
}
ongletIdentite.addEventListener('click', function () { chargerChamp('AGN_Identite'); });
ongletExperience.addEventListener('click', function () { chargerChamp('AGN_Experience'); });
ongletRelationnel.addEventListener('click', function () { chargerChamp('AGN_Relationnel'); });
ongletCompetences.addEventListener('click', function () { afficherPanneau('competences'); afficherCompetencesCollab(); });
ongletTest.addEventListener('click', ouvrirTest);
btnAbandonner.addEventListener('click', function () { chargerChamp(champActif); });
btnValider.addEventListener('click', async function () {
if (!collaborateurActif || !champActif) return;
if (mettreAJourCompteur()) return;
btnValider.disabled = true;
messageIdentite.textContent = ''; messageIdentite.className = '';
try {
const corps = { AGN_Incr: collaborateurActif.AGN_Incr };
corps[champActif] = texteIdentite.value;
const r = await fetch('collaborateurs_identite.php', {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(corps)
});
const data = await r.json();
btnValider.disabled = false;
if (!r.ok) { messageIdentite.textContent = data.error || 'Erreur'; messageIdentite.className = 'ko'; return; }
collaborateurActif[champActif] = texteIdentite.value;
messageIdentite.textContent = 'Enregistré.'; messageIdentite.className = 'ok';
} catch (e) {
btnValider.disabled = false;
messageIdentite.textContent = 'Erreur réseau'; messageIdentite.className = 'ko';
}
});
async function enregistrerExperience(agnIncr, texteUtilisateur, texteCollaborateur) {
const premierEchange = premierEchangeSession;
premierEchangeSession = false;
try {
const r = await fetch('collaborateur_experience_append.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ AGN_Incr: agnIncr, premierEchange: premierEchange, utilisateur: texteUtilisateur, collaborateur: texteCollaborateur })
});
if (!r.ok) ajouterBulle('systeme', '⚠️ Échange non enregistré dans l\'expérience (' + r.status + ')');
} catch (e) {
ajouterBulle('systeme', "⚠️ Échange non enregistré dans l'expérience (erreur réseau)");
}
}
async function envoyerMessageTest() {
const texte = chatInput.value.trim();
if (!texte || !collaborateurActif) return;
chatInput.value = '';
chatInput.disabled = true; btnChatEnvoyer.disabled = true;
ajouterBulle('moi', texte);
historiqueChatTest.push({ role: 'user', content: texte });
const attente = document.createElement('div');
attente.className = 'bulle-chat systeme';
attente.textContent = (collaborateurActif.AGN_Prenom || 'Le collaborateur') + ' réfléchit…';
chatMessages.appendChild(attente);
try {
const r = await fetch('collaborateur_dialogue.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ AGN_Incr: collaborateurActif.AGN_Incr, messages: historiqueChatTest })
});
const data = await r.json();
attente.remove();
if (!r.ok) { ajouterBulle('systeme', data.error || 'Erreur'); historiqueChatTest.pop(); return; }
historiqueChatTest.push({ role: 'assistant', content: data.reponse });
ajouterBulle('collab', data.reponse);
enregistrerExperience(collaborateurActif.AGN_Incr, texte, data.reponse);
} catch (e) {
attente.remove();
ajouterBulle('systeme', 'Erreur réseau');
historiqueChatTest.pop();
} finally {
chatInput.disabled = false; btnChatEnvoyer.disabled = false; chatInput.focus();
}
}
btnChatEnvoyer.addEventListener('click', envoyerMessageTest);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); envoyerMessageTest(); }
});
chargerCollaborateurs();
</script>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice — Compétences</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
<style>
section{background:#111;border:1px solid #262626;border-radius:6px;padding:16px 18px;margin-bottom:18px;max-width:760px;}
section h2{font-size:12px;color:#888;text-transform:uppercase;letter-spacing:.05em;font-weight:normal;margin:0 0 12px;}
label{display:block;font-size:11px;color:#999;margin:8px 0 3px;}
.grille{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.ligne{padding:8px 0;border-bottom:1px solid #1e1e1e;font-size:13px;display:flex;justify-content:space-between;gap:10px;align-items:center;}
.ligne .detail{color:#888;font-size:11px;}
.badge{font-size:11px;padding:2px 7px;border-radius:10px;background:#222;color:#999;}
</style>
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>Compétences</h1>
<p class="sous-titre">Catalogue curaté par Jean-Marc + attribution aux collaborateurs. Une compétence ne produit un effet réel que si elle est câblée dans mission_agent.php.</p>
<section>
<h2>Nouvelle compétence</h2>
<label>Code (majuscules/chiffres/underscore, ex. SITE_WEB)</label>
<input id="nvCode" placeholder="SITE_WEB">
<label>Libellé</label>
<input id="nvLibelle" placeholder="Créer et gérer un site web vitrine">
<label>Description</label>
<textarea id="nvDescription" style="width:100%;min-height:60px;"></textarea>
<div style="margin-top:10px;"><button id="btnCreerCompetence" class="principal">Créer</button>
<span id="msgCompetence" style="margin-left:10px;font-size:12px;"></span></div>
</section>
<section>
<h2>Catalogue</h2>
<div id="listeCompetences"></div>
</section>
<section>
<h2>Attribuer une compétence</h2>
<div class="grille">
<div>
<label>Collaborateur</label>
<select id="selAgent"></select>
</div>
<div>
<label>Compétence</label>
<select id="selCompetence"></select>
</div>
</div>
<div style="margin-top:10px;"><button id="btnAttribuer" class="principal">Attribuer</button>
<span id="msgAttribution" style="margin-left:10px;font-size:12px;"></span></div>
</section>
<section>
<h2>Attributions actuelles</h2>
<div id="listeAttributions"></div>
</section>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>
initBackoffice({ cibleEchap: 'backoffice.html' });
let competences = [];
let agents = [];
async function chargerCompetences() {
const r = await fetch('competences.php');
const data = await r.json();
competences = data.competences || [];
const conteneur = document.getElementById('listeCompetences');
conteneur.innerHTML = competences.length ? '' : '<p style="color:#666;font-size:12px;">Aucune compétence dans le catalogue.</p>';
competences.forEach(function (c) {
const div = document.createElement('div');
div.className = 'ligne';
div.innerHTML = '<div><strong>' + c.CMP_Code + '</strong> — ' + c.CMP_Libelle +
'<div class="detail">' + (c.CMP_Description || '') + '</div></div>';
conteneur.appendChild(div);
});
const selCompetence = document.getElementById('selCompetence');
selCompetence.innerHTML = '';
competences.forEach(function (c) {
const option = document.createElement('option');
option.value = c.CMP_Code;
option.textContent = c.CMP_Libelle + ' (' + c.CMP_Code + ')';
selCompetence.appendChild(option);
});
}
async function chargerAgents() {
const r = await fetch('agents.php');
const data = await r.json();
agents = data.agents || [];
const selAgent = document.getElementById('selAgent');
selAgent.innerHTML = '';
agents.forEach(function (a) {
const option = document.createElement('option');
option.value = a.AGN_Incr;
option.textContent = a.AGN_Prenom + (a.AGN_Fonction ? ' — ' + a.AGN_Fonction : '');
selAgent.appendChild(option);
});
}
async function chargerAttributions() {
const r = await fetch('agents_competences.php');
const data = await r.json();
const conteneur = document.getElementById('listeAttributions');
const attributions = data.attributions || [];
conteneur.innerHTML = attributions.length ? '' : '<p style="color:#666;font-size:12px;">Aucune attribution.</p>';
attributions.forEach(function (a) {
const div = document.createElement('div');
div.className = 'ligne';
div.innerHTML = '<span>' + a.AGN_Prenom + ' <span class="badge">' + a.CMP_Libelle + '</span></span>';
const btn = document.createElement('button');
btn.className = 'danger';
btn.textContent = 'Retirer';
btn.addEventListener('click', async function () {
btn.disabled = true;
await fetch('agents_competences.php', {
method: 'DELETE', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ AGC_AgentIncr: a.AGC_AgentIncr, AGC_CompetenceCode: a.AGC_CompetenceCode })
});
chargerAttributions();
});
div.appendChild(btn);
conteneur.appendChild(div);
});
}
document.getElementById('btnCreerCompetence').addEventListener('click', async function () {
const code = document.getElementById('nvCode').value.trim().toUpperCase();
const libelle = document.getElementById('nvLibelle').value.trim();
const description = document.getElementById('nvDescription').value.trim();
const msg = document.getElementById('msgCompetence');
const r = await fetch('competences.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ CMP_Code: code, CMP_Libelle: libelle, CMP_Description: description })
});
const data = await r.json();
if (!r.ok) { msg.textContent = data.error || 'Erreur'; msg.style.color = '#e14a5c'; return; }
msg.textContent = 'Créée.'; msg.style.color = '#7be07b';
document.getElementById('nvCode').value = '';
document.getElementById('nvLibelle').value = '';
document.getElementById('nvDescription').value = '';
chargerCompetences();
});
document.getElementById('btnAttribuer').addEventListener('click', async function () {
const agentIncr = document.getElementById('selAgent').value;
const competenceCode = document.getElementById('selCompetence').value;
const msg = document.getElementById('msgAttribution');
const r = await fetch('agents_competences.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ AGC_AgentIncr: agentIncr, AGC_CompetenceCode: competenceCode })
});
const data = await r.json();
if (!r.ok) { msg.textContent = data.error || 'Erreur'; msg.style.color = '#e14a5c'; return; }
msg.textContent = 'Attribuée.'; msg.style.color = '#7be07b';
chargerAttributions();
});
chargerCompetences();
chargerAgents();
chargerAttributions();
</script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice — Coût IA</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
<style>
section{background:#111;border:1px solid #262626;border-radius:6px;padding:16px 18px;margin-bottom:18px;max-width:900px;}
.total{font-size:28px;color:#bdf7bd;font-weight:700;}
table{width:100%;border-collapse:collapse;font-size:12px;}
th{text-align:left;color:#888;font-weight:normal;padding:6px 8px;border-bottom:1px solid #333;}
td{padding:6px 8px;border-bottom:1px solid #1e1e1e;color:#ccc;}
td.num{text-align:right;font-family:"Courier New",Consolas,monospace;}
</style>
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>Coût IA</h1>
<p class="sous-titre">Chaque appel réel à l'API Anthropic, journalisé — jamais deviné depuis la seule facture.</p>
<section>
<div class="total" id="total"></div>
<div id="parOrigine" style="margin-top:10px;font-size:12px;color:#999;"></div>
</section>
<section>
<h2 style="font-size:12px;color:#888;text-transform:uppercase;">50 derniers appels</h2>
<table>
<thead><tr>
<th>Date</th><th>Origine</th><th>Collaborateur</th><th>Modèle</th>
<th class="num">Entrée (frais)</th><th class="num">Entrée (cache)</th><th class="num">Sortie</th><th class="num">Coût ($)</th>
</tr></thead>
<tbody id="lignesAppels"></tbody>
</table>
</section>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>
initBackoffice({ cibleEchap: 'backoffice.html' });
async function charger() {
const r = await fetch('appels_ia.php');
const data = await r.json();
document.getElementById('total').textContent = '$' + Number(data.total_usd).toFixed(4) + ' au total';
document.getElementById('parOrigine').innerHTML = (data.par_origine || []).map(function (o) {
return o.AIA_Origine + ' : ' + o.nb_appels + ' appel(s), $' + Number(o.cout_usd).toFixed(4);
}).join(' — ');
const corps = document.getElementById('lignesAppels');
corps.innerHTML = '';
(data.derniers || []).forEach(function (a) {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + a.AIA_Date + '</td>' +
'<td>' + a.AIA_Origine + '</td>' +
'<td>' + (a.AGN_Prenom || '—') + '</td>' +
'<td>' + a.AIA_Modele + '</td>' +
'<td class="num">' + a.AIA_TokensEntreeFrais + '</td>' +
'<td class="num">' + a.AIA_TokensEntreeCache + '</td>' +
'<td class="num">' + a.AIA_TokensSortie + '</td>' +
'<td class="num">$' + Number(a.AIA_CoutEstimeUsd).toFixed(5) + '</td>';
corps.appendChild(tr);
});
}
charger();
</script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice — Espaces disque</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
<style>
section{background:#111;border:1px solid #262626;border-radius:6px;padding:16px 18px;max-width:820px;}
.ligne{padding:9px 0;border-bottom:1px solid #1e1e1e;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:10px;}
.chemin{font-family:"Courier New",Consolas,monospace;color:#8ab;font-size:12px;}
.badge{font-size:11px;padding:2px 7px;border-radius:10px;}
.badge.oui{background:#2a4d2a;color:#bdf7bd;}
.badge.non{background:#4d3a2a;color:#f7d7bd;}
</style>
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>Espaces disque</h1>
<p class="sous-titre">Chaque utilisateur MIRADOR a un dossier privé, jamais atteignable par une URL. Cette page ne sert qu'à vérifier — l'opacité du nom protège d'un visiteur extérieur, jamais de toi.</p>
<section>
<div id="listeEspaces">Chargement…</div>
</section>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>
initBackoffice({ cibleEchap: 'backoffice.html' });
function formaterTaille(octets) {
if (octets < 1024) return octets + ' o';
if (octets < 1024*1024) return (octets/1024).toFixed(1) + ' Ko';
return (octets/1024/1024).toFixed(2) + ' Mo';
}
async function charger() {
const r = await fetch('espaces.php');
const data = await r.json();
const conteneur = document.getElementById('listeEspaces');
conteneur.innerHTML = '';
(data.espaces || []).forEach(function (e) {
const div = document.createElement('div');
div.className = 'ligne';
div.innerHTML =
'<div><strong>' + e.MIR_Email + '</strong> (' + e.MIR_BaseNom + ')<br>' +
'<span class="chemin">' + e.dossier_relatif + '</span></div>' +
'<div style="text-align:right;">' +
'<span class="badge ' + (e.existe ? 'oui' : 'non') + '">' + (e.existe ? 'créé — ' + formaterTaille(e.taille_octets) : 'pas encore créé') + '</span>' +
'</div>';
if (!e.existe) {
const btn = document.createElement('button');
btn.className = 'principal';
btn.textContent = 'Provisionner';
btn.style.marginLeft = '10px';
btn.addEventListener('click', async function () {
btn.disabled = true;
await fetch('espaces.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'provisionner', MIR_Email: e.MIR_Email })
});
charger();
});
div.querySelector('div:last-child').appendChild(btn);
}
conteneur.appendChild(div);
});
if (!(data.espaces || []).length) conteneur.textContent = 'Aucun utilisateur dans MIRADOR.';
}
charger();
</script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — BackOffice — Prompts</title>
<link rel="icon" href="mevelia-icone.png">
<link rel="stylesheet" href="backoffice.css">
<style>
section{background:#111;border:1px solid #262626;border-radius:6px;padding:16px 18px;margin-bottom:18px;max-width:820px;}
section h2{font-size:13px;color:#ddd;font-weight:700;margin:0 0 2px;}
section .code{font-size:11px;color:#888;font-family:monospace;margin-bottom:10px;}
section textarea{width:100%;min-height:260px;resize:vertical;font-family:monospace;font-size:12px;line-height:1.5;}
.ligne-actions{display:flex;justify-content:space-between;align-items:center;margin-top:8px;}
.compteur{font-size:11px;color:#888;}
.msg{font-size:12px;min-height:14px;}
.msg.ok{color:#7be07b;}
.msg.ko{color:#e14a5c;}
</style>
</head>
<body>
<div id="bo-cadre">
<div id="bo-corps">
<nav id="bo-menu"></nav>
<main id="bo-contenu">
<h1>Prompts</h1>
<p class="sous-titre">Prompts système IA du projet — texte brut avec placeholders {{...}}, remplis par le code au moment de l'appel. Jamais consultable par un utilisateur final.</p>
<div id="liste-prompts">Chargement…</div>
</main>
</div>
<footer id="bo-pied"></footer>
</div>
<script src="backoffice.js"></script>
<script>
initBackoffice({ cibleEchap: 'backoffice.html' });
const conteneur = document.getElementById('liste-prompts');
function creerSection(p) {
const section = document.createElement('section');
const titre = document.createElement('h2');
titre.textContent = p.PMT_Libelle;
section.appendChild(titre);
const code = document.createElement('div');
code.className = 'code';
code.textContent = p.PMT_Code;
section.appendChild(code);
const texte = document.createElement('textarea');
texte.spellcheck = false;
texte.value = p.PMT_Texte || '';
section.appendChild(texte);
const ligneActions = document.createElement('div');
ligneActions.className = 'ligne-actions';
const compteur = document.createElement('span');
compteur.className = 'compteur';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'principal';
btn.textContent = 'Enregistrer';
ligneActions.appendChild(compteur);
ligneActions.appendChild(btn);
section.appendChild(ligneActions);
const msg = document.createElement('div');
msg.className = 'msg';
section.appendChild(msg);
function majCompteur() {
compteur.textContent = texte.value.length.toLocaleString('fr-FR') + ' caractères';
}
texte.addEventListener('input', majCompteur);
majCompteur();
btn.addEventListener('click', async function () {
btn.disabled = true;
msg.textContent = ''; msg.className = 'msg';
try {
const r = await fetch('prompts.php', {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ PMT_Code: p.PMT_Code, PMT_Texte: texte.value })
});
const data = await r.json();
btn.disabled = false;
if (!r.ok) { msg.textContent = data.error || 'Erreur'; msg.className = 'msg ko'; return; }
msg.textContent = 'Enregistré.'; msg.className = 'msg ok';
} catch (e) {
btn.disabled = false;
msg.textContent = 'Erreur réseau'; msg.className = 'msg ko';
}
});
return section;
}
async function chargerPrompts() {
try {
const r = await fetch('prompts.php');
const data = await r.json();
if (!r.ok) { conteneur.textContent = data.error || 'Erreur'; return; }
const prompts = data.prompts || [];
conteneur.innerHTML = '';
if (!prompts.length) {
conteneur.innerHTML = '<p style="color:#666;font-size:12px;">Aucun prompt.</p>';
return;
}
prompts.forEach(function (p) { conteneur.appendChild(creerSection(p)); });
} catch (e) {
conteneur.textContent = 'Erreur réseau';
}
}
chargerPrompts();
</script>
</body>
</html>
+337
View File
@@ -0,0 +1,337 @@
<?php
/**
* ASPIC — collaborateur_dialogue.php
*
* Endpoint interne (10 septembre 2026, réflexions stratégiques section 20 —
* "Tester l'identité") — PAS un endpoint destiné aux futurs utilisateurs à ce
* stade, même esprit que collaborateurs_identite.php/contact_ia_interprete.php :
* un banc d'essai réservé à Jean-Marc, pour dialoguer en conditions réelles
* avec un collaborateur et juger si l'identité qu'il vient de lui écrire
* (AGN_Identite, voir collaborateurs_identite.php) "sonne juste" avant de
* l'affiner.
*
* Reçoit (POST, JSON) :
* { "AGN_Incr": 3, "messages": [ {"role":"user","content":"..."}, ... ] }
* — l'historique complet de la discussion en cours, maintenu côté front-end
* (voir ecran_fond.html) : l'API Anthropic est sans état, chaque appel doit
* réexpédier tout l'échange précédent. Renvoie { "reponse": "..." }, le
* prochain message du collaborateur.
*
* Ce fichier construit le prompt système à partir d'AGN_Prenom/AGN_Fonction/
* AGN_Identite/AGN_Relationnel/AGN_Experience/AGN_Competences — et SEULEMENT
* à partir de ça : jamais ces champs renvoyés eux-mêmes au front-end (voir
* collaborateurs_identite.php, même principe — l'historique de vie et
* d'expérience d'un collaborateur reste invisible d'un bout à l'autre de la
* chaîne, seule sa "voix" en sort, via les réponses du modèle). Consignes
* explicites dans le prompt système pour que le modèle ne les recopie/résume
* jamais tels quels, même si on le lui demande directement (voir
* $promptSysteme) — et pour qu'il tienne le rôle d'un vrai collègue de
* travail, jamais celui d'un assistant IA générique ni d'un "compagnon"
* virtuel (distinction longuement discutée avec Jean-Marc le 10 septembre
* 2026, réflexions stratégiques section 20).
*
* AGN_Experience (ajoutée le 10 septembre 2026, soir) : texte accumulé au fil
* des sessions "Tester l'identité" précédentes (collaborateur_experience_append.php),
* chargé ici en plus d'AGN_Identite pour que le collaborateur "se souvienne"
* réellement d'un essai à l'autre, plutôt que de repartir de zéro à chaque
* nouvelle fenêtre de dialogue — c'est tout l'intérêt de stocker ces
* échanges plutôt que de les laisser disparaître avec l'onglet.
*
* Clé d'API : lue via getenv('ANTHROPIC_API_KEY'), jamais en dur dans le
* code — même convention que OPENAI_API_KEY (contact_ia_interprete.php) et
* DB_USER/DB_PASS (db.php). Si absente, l'endpoint répond 500 avec un
* message explicite. Ajoutée par Jean-Marc le 10 septembre 2026 dans
* l'environnement des DEUX conteneurs déployés (dev et test).
*
* Modèle utilisé : constante ANTHROPIC_MODEL ci-dessous, volontairement
* isolée (même principe qu'OPENAI_MODEL) — claude-sonnet-5 par défaut,
* équilibre qualité/coût suffisant pour juger si une identité "sonne juste"
* en test ; à remonter en claude-opus-5 si le rendu manque de nuance, ou à
* descendre en Haiku si le coût des essais répétés pèse trop.
*
* ⚠️ Point non vérifié en conditions réelles : cette session n'a pas la main
* pour exécuter un vrai appel à l'API Anthropic (pas de clé disponible ici).
* L'appel ci-dessous suit la documentation Anthropic (endpoint Messages,
* en-têtes Content-Type/anthropic-version/X-Api-Key) vérifiée le 10 septembre
* 2026, mais mérite un premier essai réel pour confirmer que rien n'a divergé
* entre-temps. Si le premier essai échoue, le message d'erreur renvoyé par
* Anthropic est transmis tel quel au front-end (voir gestion d'erreur
* ci-dessous) pour pouvoir corriger vite sans deviner.
*
* ⚠️ Session utilisateur : accès réservé aux utilisateurs identifiés
* (`$_SESSION['USR_Code']`, ouverte par aspic.php) — même principe simplifié
* que collaborateurs_identite.php (pas de vérification renforcée par
* USR_Code précis, décision de Jean-Marc du 10 septembre 2026, à revoir dès
* qu'un second utilisateur réel aura un compte MIRADOR sur la même base).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/journal_ia.php';
require __DIR__ . '/moteur_prompts.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
// Prénom de l'utilisateur connecté (posé en session par aspic.php au login),
// jamais "Jean-Marc" en dur dans le prompt (11 septembre 2026, point relevé
// par Jean-Marc lui-même : ASPIC est censé pouvoir un jour tourner pour un
// autre patron, Joseph ou Sylvie).
$patron = trim((string) ($_SESSION['USR_Prenom'] ?? '')) ?: "l'utilisateur";
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
const ANTHROPIC_MODEL = 'claude-sonnet-5';
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
const ANTHROPIC_MAX_TOKENS = 1024;
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$agnIncr = (int) ($data['AGN_Incr'] ?? 0);
$messages = $data['messages'] ?? [];
if ($agnIncr <= 0) {
http_response_code(400);
echo json_encode(['error' => 'AGN_Incr manquant ou invalide']);
exit;
}
if (!is_array($messages) || !$messages) {
http_response_code(400);
echo json_encode(['error' => 'Aucun message à transmettre']);
exit;
}
// trim() : même précaution que pour OPENAI_API_KEY (contact_ia_interprete.php)
// — un espace resté collé en copiant-collant la clé dans docker-compose/.env
// suffit à faire échouer l'authentification avec un message qui ne le laisse
// pas deviner.
$apiKey = trim((string) getenv('ANTHROPIC_API_KEY'));
if (!$apiKey) {
http_response_code(500);
echo json_encode(['error' => "Clé ANTHROPIC_API_KEY absente côté serveur — à ajouter dans l'environnement du conteneur avant de pouvoir tester."]);
exit;
}
// ANTHROPIC_WORKSPACE_ID (optionnelle) — ajoutée le 10 septembre 2026 :
// premier essai réel de Jean-Marc en erreur HTTP 400, message Anthropic sans
// ambiguïté : « This API key is not scoped to a workspace, so this request
// must include the anthropic-workspace-id header ». Arrive avec une clé
// personnelle/de type "organisation" (pas liée à un espace de travail en
// particulier dans la Console) plutôt qu'avec une clé créée directement DANS
// un espace de travail (celles-ci n'ont pas besoin de cet en-tête). Les deux
// façons de résoudre, au choix de Jean-Marc :
// 1. Recréer la clé depuis platform.claude.com/settings/keys en choisissant
// explicitement un espace de travail précis (pas "Toute l'organisation")
// au moment de la création — alors cette variable n'est pas nécessaire.
// 2. Garder la clé actuelle et poser ANTHROPIC_WORKSPACE_ID (format
// wrkspc_..., visible dans les réglages de l'espace de travail concerné
// sur platform.claude.com) à côté d'ANTHROPIC_API_KEY dans l'environnement
// du conteneur.
// Header ajouté seulement si la variable est posée, pour ne rien changer au
// comportement le jour où Jean-Marc bascule sur une clé déjà liée à un espace
// de travail (option 1).
$workspaceId = trim((string) getenv('ANTHROPIC_WORKSPACE_ID'));
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
$stmt = $pdo->prepare(
'SELECT AGN_Prenom, AGN_Fonction, AGN_Identite, AGN_Relationnel, AGN_Experience, AGN_Competences FROM ASP_Agents WHERE AGN_Incr = ?'
);
$stmt->execute([$agnIncr]);
$collaborateur = $stmt->fetch();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
exit;
}
if (!$collaborateur) {
http_response_code(404);
echo json_encode(['error' => 'Collaborateur introuvable']);
exit;
}
$prenom = $collaborateur['AGN_Prenom'] ?: 'ce collaborateur';
$fonction = $collaborateur['AGN_Fonction'] ?: '';
$identite = trim((string) ($collaborateur['AGN_Identite'] ?? ''));
$experience = trim((string) ($collaborateur['AGN_Experience'] ?? ''));
$blocIdentite = $identite !== ''
? $identite
: "(Aucune identité détaillée n'a encore été écrite pour {$prenom} — tiens-toi en à son rôle : {$fonction}.)";
// AGN_Relationnel (11 septembre 2026) : posture relationnelle envers l'équipe
// et Jean-Marc — statique comme AGN_Identite, volontairement distincte pour
// que le tri de l'identité individuelle (caractère, goûts) n'ait pas à se
// mélanger avec le rapport à l'équipe qui donne du sens à l'ensemble (voir
// réflexions stratégiques). Même statut qu'AGN_Identite dans le prompt : pas
// de garde anti-récitation dédiée, elle reste sous la consigne générale de
// l'intro ("aide-mémoire à ton seul usage, jamais un texte à réciter")
// puisque c'est un texte rédigé par Jean-Marc, pas une liste factuelle comme
// AGN_Competences (voir plus bas pourquoi celle-ci en a besoin).
$relationnel = trim((string) ($collaborateur['AGN_Relationnel'] ?? ''));
$blocRelationnel = $relationnel !== ''
? "\n\nTa manière d'être avec l'équipe et avec {$patron} :\n\n{$relationnel}"
: '';
// Bloc optionnel : absent du prompt tant qu'aucun échange n'a encore été
// capturé (collaborateur flambant neuf, ou expérience remise à zéro via
// "Modifier l'expérience") — pas de bloc vide ni de phrase de remplissage
// inutile dans ce cas.
$blocExperience = $experience !== ''
? "\n\nVoici ce que tu retiens de vos échanges précédents avec {$patron} (essais de dialogue passés) :\n\n{$experience}"
: '';
// Anti-récitation propre à ce bloc plutôt que de compter sur la consigne
// générale plus haut dans le prompt (voir $promptSysteme) : premier essai
// réel (11 septembre 2026) montrant qu'une compétence du catalogue formel
// (ASP_Competences, pensé au départ comme mécanique — voir en-tête de
// competences.php) était citée telle quelle par le modèle, y compris une
// compétence posée en test ("savoir faire un bon café") sans intention
// qu'elle infuse la personnalité — ce catalogue n'est pas écrit pour être
// lu tel quel dans une conversation, contrairement à AGN_Identite.
$competencesAgent = trim((string) ($collaborateur['AGN_Competences'] ?? ''));
$blocCompetences = $competencesAgent !== ''
? "\n\nTes compétences déclarées (ce que tu sais concrètement faire ou apporter — "
. "un aide-mémoire pour savoir ce que tu peux proposer, jamais une liste à réciter, "
. "énumérer ou commenter telle quelle, même si on te le demande directement ; "
. "une entrée isolée ou insolite ne mérite pas plus d'attention qu'une autre) :\n\n{$competencesAgent}"
: '';
// -------- Prompt système --------
// Texte sorti du code le 11 septembre 2026 (Jean-Marc veut pouvoir le lire/
// modifier lui-même) — voir moteur_prompts.php pour le texte par défaut, la
// table ASP_Prompts et bo_prompts.html pour l'édition. Distinction "collègue
// de travail" / "assistant IA" / "compagnon virtuel" et règle de
// non-divulgation d'AGN_Identite : décisions du 10 septembre 2026 désormais
// actées dans ce texte, plus dans ce fichier PHP.
try {
$promptSysteme = chargerPromptSysteme($pdo, 'COLLABORATEUR_DIALOGUE', [
'{{PRENOM}}' => $prenom,
'{{FONCTION}}' => $fonction,
'{{PATRON}}' => $patron,
'{{BLOC_IDENTITE}}' => $blocIdentite,
'{{BLOC_RELATIONNEL}}' => $blocRelationnel,
'{{BLOC_EXPERIENCE}}' => $blocExperience,
'{{BLOC_COMPETENCES}}' => $blocCompetences,
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Prompt système indisponible : ' . $e->getMessage()]);
exit;
}
// -------- Historique --------
// Reconstruit et validé plutôt que transmis tel quel : seuls role/content
// sont conservés, seuls "user"/"assistant" acceptés (même précaution
// défensive que contacts.php/contact_ia_interprete.php envers toute donnée
// venue du front-end).
$historique = [];
foreach ($messages as $m) {
$role = $m['role'] ?? '';
$contenu = trim((string) ($m['content'] ?? ''));
if (!in_array($role, ['user', 'assistant'], true) || $contenu === '') {
continue;
}
$historique[] = ['role' => $role, 'content' => $contenu];
}
if (!$historique) {
http_response_code(400);
echo json_encode(['error' => 'Aucun message valide à transmettre']);
exit;
}
// -------- Appel Anthropic --------
// system en tableau de blocs + cache_control (11 septembre 2026, voir
// journal_ia.php) : ce prompt (identité + expérience) ne change pas d'un
// tour à l'autre d'une même session de test, mais était jusqu'ici refacturé
// en entier à CHAQUE appel — le cache Anthropic permet de ne payer le tarif
// plein qu'une fois, puis un tarif réduit (~1/10ᵉ) tant que rien ne change.
// ⚠️ Comme pour le reste de ce fichier, mécanisme suivant la documentation
// Anthropic mais jamais éprouvé en conditions réelles avant ce soir.
$corpsRequete = [
'model' => ANTHROPIC_MODEL,
'max_tokens' => ANTHROPIC_MAX_TOKENS,
'system' => [
['type' => 'text', 'text' => $promptSysteme, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => $historique,
];
$entetes = [
'Content-Type: application/json',
'anthropic-version: ' . ANTHROPIC_VERSION,
'X-Api-Key: ' . $apiKey,
];
// Voir commentaire ANTHROPIC_WORKSPACE_ID plus haut — en-tête ajouté
// uniquement si la variable est posée.
if ($workspaceId) {
$entetes[] = 'anthropic-workspace-id: ' . $workspaceId;
}
$ch = curl_init(ANTHROPIC_API_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpsRequete),
CURLOPT_HTTPHEADER => $entetes,
CURLOPT_TIMEOUT => 60,
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 Anthropic impossible : " . $erreurCurl]);
exit;
}
$reponseDecodee = json_decode($reponseBrute, true);
if ($codeHttp !== 200) {
http_response_code(502);
echo json_encode([
'error' => "L'API Anthropic a renvoyé une erreur (HTTP $codeHttp) : " .
($reponseDecodee['error']['message'] ?? $reponseBrute),
]);
exit;
}
// Réponse Messages API : content est un tableau de blocs ({"type":"text",
// "text":"..."} pour une réponse texte simple, seul cas utilisé ici — pas
// d'appel d'outil configuré).
$texteReponse = '';
foreach ($reponseDecodee['content'] ?? [] as $bloc) {
if (($bloc['type'] ?? '') === 'text') {
$texteReponse .= $bloc['text'];
}
}
if ($texteReponse === '') {
http_response_code(502);
echo json_encode(['error' => "Réponse Anthropic inattendue (pas de contenu texte) : " . $reponseBrute]);
exit;
}
// Journalisation du coût réel (11 septembre 2026, voir journal_ia.php) —
// jamais bloquant, jamais silencieux côté serveur en cas d'échec.
journaliserAppelIA($pdo, 'collaborateur_dialogue', $agnIncr, null, ANTHROPIC_MODEL, $reponseDecodee['usage'] ?? []);
echo json_encode(['reponse' => $texteReponse]);
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* ASPIC — collaborateur_experience_append.php
*
* Endpoint interne (10 septembre 2026, réflexions stratégiques section 20,
* suite) — même famille que collaborateurs_identite.php/
* collaborateur_dialogue.php : un outil de mise au point réservé à Jean-Marc,
* jamais destiné aux futurs utilisateurs.
*
* Appelé automatiquement par ecran_fond.html "au fil de l'eau", après CHAQUE
* réponse reçue du collaborateur pendant un essai "Tester l'identité" — pas
* seulement à la fermeture de la fenêtre de dialogue. Décision de Jean-Marc
* (10 septembre 2026) : il s'est rendu compte qu'il donne parfois, en
* testant, de vraies informations sur son projet (pas seulement des
* réglages de personnalité) et qu'il serait "un peu idiot" de les perdre si
* la fenêtre se ferme sans prévenir (fermeture d'onglet, plantage du
* navigateur) — d'où un ajout systématique dès que la réponse arrive,
* jamais différé à un clic sur "Fermer".
*
* Ajoute (ne remplace JAMAIS) un tour d'échange complet — ce que Jean-Marc a
* dit ET ce que le collaborateur a répondu, les deux, pas seulement la
* réponse — au texte déjà accumulé dans AGN_Experience. Ajout atomique côté
* SQL (CONCAT dans l'UPDATE, sans lecture préalable côté PHP) : pas de
* risque d'écraser un ajout concurrent, même si peu probable ici (Jean-Marc
* seul utilisateur réel à ce jour).
*
* Ce texte accumulé est volontairement brut, sans tri ni interprétation —
* c'est justement le rôle de "Modifier l'expérience" (collaborateurs_identite.php,
* PUT sur AGN_Experience) de permettre à Jean-Marc de faire le ménage après
* coup (retirer ce qui ne concernait que la mise au point de l'identité,
* garder ce qui a une vraie valeur de mémoire).
*
* Reçoit (POST, JSON) :
* { "AGN_Incr": 3, "premierEchange": true,
* "utilisateur": "...", "collaborateur": "..." }
* - "utilisateur"/"collaborateur" : le tour de dialogue qui vient d'avoir
* lieu (texte brut, jamais renvoyé ni ré-affiché ensuite par cet endpoint).
* - "premierEchange" (bool) : vrai pour le tout premier tour d'une session de
* test (voir selectionnerCollaborateurTest() dans ecran_fond.html) — fait
* précéder le tour d'un en-tête horodaté, pour distinguer les sessions
* entre elles dans le texte accumulé ; faux pour les tours suivants de la
* même session, qui s'ajoutent à la suite sans répéter cet en-tête.
*
* Le nom affiché pour Jean-Marc n'est jamais envoyé par le front : lu
* directement dans $_SESSION['USR_Prenom'] (ouverte par aspic.php), même
* principe que REA_NomAffiche dans realisations.php — une donnée de session
* fait foi, jamais une valeur transmise par le client.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$incr = (int) ($data['AGN_Incr'] ?? 0);
$premierEchange = !empty($data['premierEchange']);
$texteUtilisateur = trim((string) ($data['utilisateur'] ?? ''));
$texteCollaborateur = trim((string) ($data['collaborateur'] ?? ''));
if ($incr <= 0) {
http_response_code(400);
echo json_encode(['error' => 'AGN_Incr manquant ou invalide']);
exit;
}
if ($texteUtilisateur === '' || $texteCollaborateur === '') {
http_response_code(400);
echo json_encode(['error' => 'Échange incomplet (message et réponse attendus tous les deux)']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
$verif = $pdo->prepare('SELECT AGN_Prenom FROM ASP_Agents WHERE AGN_Incr = ?');
$verif->execute([$incr]);
$agent = $verif->fetch();
if (!$agent) {
http_response_code(404);
echo json_encode(['error' => 'Collaborateur introuvable']);
exit;
}
$nomUtilisateur = $_SESSION['USR_Prenom'] ?? 'Jean-Marc';
$nomCollaborateur = $agent['AGN_Prenom'] ?: 'Collaborateur';
$bloc = '';
if ($premierEchange) {
$bloc .= "\n--- " . date('d/m/Y H:i') . ' — session de test avec ' . $nomUtilisateur . " ---\n";
}
$bloc .= '[' . $nomUtilisateur . '] ' . $texteUtilisateur . "\n";
$bloc .= '[' . $nomCollaborateur . '] ' . $texteCollaborateur . "\n";
// CONCAT atomique : jamais de lecture préalable du texte existant côté
// PHP, donc jamais de risque d'écraser un ajout concurrent avec une
// valeur devenue périmée entre temps.
$stmt = $pdo->prepare(
"UPDATE ASP_Agents SET AGN_Experience = CONCAT(COALESCE(AGN_Experience, ''), ?) WHERE AGN_Incr = ?"
);
$stmt->execute([$bloc, $incr]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* ASPIC — collaborateurs_identite.php
*
* Endpoint interne (10 septembre 2026, réflexions stratégiques section 20 —
* "collaborateurs", identité propre à chacun) — PAS un endpoint destiné aux
* futurs utilisateurs, sur le même principe que dictees_test.php/
* contact_ia_test.html : un outil de mise au point réservé à Jean-Marc.
*
* Lit/écrit deux colonnes texte libre sur ASP_Agents, éditées à la main par
* Jean-Marc en base avant tout premier essai — même principe qu'AGN_Variantes/
* AGN_Photo :
*
* ALTER TABLE ASP_Agents
* ADD COLUMN AGN_Identite TEXT NULL DEFAULT NULL AFTER AGN_Couleur;
*
* - AGN_Identite : parcours de vie, expérience professionnelle, traits de
* caractère — écrit par Jean-Marc lui-même via "Construire une identité".
* - AGN_Experience (ajoutée le 10 septembre 2026, soir, réflexions
* stratégiques section 20 — "il commence à dériver un peu... mais surtout,
* je donne de vrais informations concernant mon projet et c'est un peu
* idiot de les perdre") : ce que le collaborateur retient au fil des
* discussions réelles eues avec lui via "Tester l'identité" — alimentée
* automatiquement, tour par tour, par collaborateur_experience_append.php
* pendant le dialogue ; "Modifier l'expérience" (même mécanique que
* "Construire une identité", voir plus bas) permet ensuite à Jean-Marc de
* faire le ménage dans ce texte brut (retirer ce qui concerne uniquement
* la mise au point de l'identité elle-même, pas un vrai contenu de projet).
*
* Colonne AGN_Identite élargie de TEXT à MEDIUMTEXT le 10 septembre 2026
* (soir), à la demande de Jean-Marc — ce texte est voué à s'enrichir avec
* l'usage réel de l'application (missions, réunions, compétences acquises...)
* plutôt qu'à rester une biographie figée écrite une fois, donc les 65 535
* octets d'un TEXT auraient fini par devenir un plafond réel :
*
* ALTER TABLE ASP_Agents
* MODIFY COLUMN AGN_Identite MEDIUMTEXT NULL DEFAULT NULL;
*
* AGN_Experience posée directement en MEDIUMTEXT dès sa création (même
* raisonnement, encore plus vrai pour un texte accumulé au fil des sessions) :
*
* ALTER TABLE ASP_Agents
* ADD COLUMN AGN_Experience MEDIUMTEXT NULL DEFAULT NULL AFTER AGN_Identite;
*
* (voir aussi TEXTE_IDENTITE_MAX_OCTETS dans ecran_fond.html, qui reflète
* cette même limite côté indicateur de saisie — partagé par les deux champs.)
*
* Volontairement séparé d'agents.php : celui-ci reste l'endpoint de lecture
* publique consommé par ecran_fond.html (portraits, couleurs, rôles) — jamais
* question qu'AGN_Identite ou AGN_Experience y transitent, puisque ces deux
* données doivent rester invisibles d'un utilisateur ordinaire (décision
* explicite de Jean-Marc, réflexions stratégiques section 20 : l'identité de
* chaque collaborateur n'est pas consultable telle quelle, seule une
* présentation synthétisée par le collaborateur lui-même pourra un jour en
* être tirée, dans une future Mission — voir réflexions stratégiques section
* 18). Même principe pour AGN_Experience, par cohérence.
*
* Accès : simple session ouverte, même principe que tous les autres endpoints
* du projet (contacts.php, todolist.php...) — PAS de vérification renforcée
* par USR_Code précis. Décision explicite de Jean-Marc (10 septembre 2026,
* revenant sur le premier réglage plus strict envisagé) : il reste seul
* utilisateur réel de l'application pour encore plusieurs mois, inutile de
* s'imposer une friction (retrouver puis coder en dur son propre USR_Code)
* pour un risque nul dans l'immédiat. Cette page reste malgré tout un outil
* interne, jamais lié depuis la navigation normale de l'application — rien
* n'affiche AGN_Identite ailleurs (voir plus haut).
* ⚠️ À REVOIR le jour où un second utilisateur réel (collaborateur humain,
* client bêta) aura un compte MIRADOR sur la même base : remettre une
* vérification par rôle (ex. nouvelle colonne USR_Administrateur, encore
* inexistante à ce jour) avant d'ouvrir l'accès à qui que ce soit d'autre que
* Jean-Marc.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
try {
$stmt = $pdo->query(
'SELECT AGN_Incr, AGN_Prenom, AGN_Fonction, AGN_Photo, AGN_Couleur, AGN_Identite, AGN_Experience, AGN_Relationnel
FROM ASP_Agents ORDER BY AGN_OrdreAffichage ASC'
);
echo json_encode(['collaborateurs' => $stmt->fetchAll()]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
if ($method === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$incr = (int) ($data['AGN_Incr'] ?? 0);
// Un seul des champs à la fois dans le corps de la requête — jamais
// plusieurs ensemble côté front (voir champActif dans bo_collaborateurs.html :
// un onglet par champ — Identité/Expérience/Relationnel — jamais deux
// édités dans le même appel). AGN_Relationnel (11 septembre 2026) : posture
// relationnelle du collaborateur envers l'équipe, statique pour l'instant
// — distincte d'AGN_Identite (caractère individuel) et d'AGN_Experience
// (qui s'enrichit par append, voir collaborateur_experience_append.php).
// Whitelist stricte plutôt qu'un nom de colonne construit depuis une clé
// arbitraire du JSON reçu, même si en pratique cette donnée ne vient
// jamais d'ailleurs que du front interne — même esprit défensif que le
// reste du projet (voir contacts.php, contact_ia_interprete.php).
$champsAutorises = ['AGN_Identite', 'AGN_Experience', 'AGN_Relationnel'];
$champ = null;
foreach ($champsAutorises as $c) {
if (array_key_exists($c, $data)) {
$champ = $c;
break;
}
}
if ($incr <= 0) {
http_response_code(400);
echo json_encode(['error' => 'AGN_Incr manquant ou invalide']);
exit;
}
if ($champ === null) {
http_response_code(400);
echo json_encode(['error' => 'Aucun champ à mettre à jour (AGN_Identite ou AGN_Experience attendu)']);
exit;
}
// Champ volontairement libre, sans limite de longueur imposée ici
// (MEDIUMTEXT côté base, voir en-tête) : une identité — ou une expérience
// accumulée — bien écrite peut dépasser largement la taille d'un champ de
// fiche habituel (Nom, Ville...) de ce projet, et grandira encore avec
// l'usage réel de l'application — le seul garde-fou vit côté front-end
// (voir TEXTE_IDENTITE_MAX_OCTETS/compteur-identite dans ecran_fond.html).
$valeur = trim((string) ($data[$champ] ?? ''));
try {
// Existence vérifiée par un SELECT séparé plutôt que sur rowCount() de
// l'UPDATE (même précaution que contacts.php, section PUT — un texte
// identique au texte déjà enregistré donnerait rowCount() = 0 alors
// que le collaborateur existe bien).
$verif = $pdo->prepare('SELECT AGN_Incr FROM ASP_Agents WHERE AGN_Incr = ?');
$verif->execute([$incr]);
if (!$verif->fetch()) {
http_response_code(404);
echo json_encode(['error' => 'Collaborateur introuvable']);
exit;
}
// $champ vient exclusivement de $champsAutorises ci-dessus, jamais
// directement de $data : interpolation sans risque d'injection.
$stmt = $pdo->prepare("UPDATE ASP_Agents SET {$champ} = ? WHERE AGN_Incr = ?");
$stmt->execute([$valeur !== '' ? $valeur : null, $incr]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* ASPIC — competences.php
*
* Endpoint du BackOffice (11 septembre 2026, chantier Compétences/Missions)
* — catalogue des compétences (`ASP_Competences`), curaté par Jean-Marc, même
* principe que le catalogue de consignes (voir moteur_consignes.php) : c'est
* `mission_agent.php` (le code), pas ce catalogue, qui définit ce qu'une
* compétence fait réellement — ce catalogue ne porte que le libellé et la
* description affichés, jamais une définition d'outil exécutable.
*
* GET : liste toutes les compétences.
* POST { "CMP_Code": "...", "CMP_Libelle": "...", "CMP_Description": "..." }
* Crée une nouvelle entrée de catalogue. Ne câble aucun outil réel — voir
* remarque d'en-tête : ça reste à faire à la main dans mission_agent.php
* le jour où cette compétence doit produire un effet réel.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$competences = $pdo->query('SELECT CMP_Code, CMP_Libelle, CMP_Description, CMP_DateCreation FROM ASP_Competences ORDER BY CMP_Libelle')->fetchAll();
echo json_encode(['competences' => $competences]);
exit;
}
if ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$code = trim((string) ($data['CMP_Code'] ?? ''));
$libelle = trim((string) ($data['CMP_Libelle'] ?? ''));
$description = trim((string) ($data['CMP_Description'] ?? ''));
if ($code === '' || $libelle === '') {
http_response_code(400);
echo json_encode(['error' => 'CMP_Code et CMP_Libelle sont obligatoires.']);
exit;
}
// Whitelist de forme plutôt qu'une simple absence de vérification : un
// code de compétence est aussi un identifiant utilisé en dur dans
// mission_agent.php (outilsDeLaCompetence()) — jamais d'espace ni de
// caractère qui compliquerait sa lecture dans le code PHP.
if (!preg_match('/^[A-Z0-9_]+$/', $code)) {
http_response_code(400);
echo json_encode(['error' => 'CMP_Code ne doit contenir que des majuscules, chiffres et underscores (ex. SITE_WEB).']);
exit;
}
try {
$stmt = $pdo->prepare('SELECT 1 FROM ASP_Competences WHERE CMP_Code = ?');
$stmt->execute([$code]);
if ($stmt->fetch()) {
http_response_code(409);
echo json_encode(['error' => "La compétence $code existe déjà."]);
exit;
}
$pdo->prepare('INSERT INTO ASP_Competences (CMP_Code, CMP_Libelle, CMP_Description) VALUES (?, ?, ?)')
->execute([$code, $libelle, $description !== '' ? $description : null]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+90
View File
@@ -0,0 +1,90 @@
<?php
/**
* ASPIC — consignes_apercu.php (7 septembre 2026, soir)
*
* Endpoint dédié au badge IA générique (maquette fournie par Jean-Marc, voir
* journal) : répond très vite, SANS jamais appeler le moindre service
* externe, à la question « si j'enregistrais ces valeurs maintenant, au
* moins une consigne de Traitement IA se déclencherait-elle ? ».
*
* Existe pour une seule raison : ne JAMAIS deviner ni recopier en dur, côté
* écran, la condition qui déclenche une consigne IA (voir les échanges du
* 7 septembre 2026 dans le journal — Jean-Marc a refusé un premier essai qui
* testait `ville renseignée et CP vide` directement en JavaScript). La seule
* condition qui compte est celle écrite dans le jumeau
* "<fonction>_seraitDeclenchee" de la fonction elle-même (voir en-tête de
* consignes_fonctions.php) ; cet endpoint se contente d'interroger
* auMoinsUneConsigneIaSeraitDeclenchee() (moteur_consignes.php), qui parcourt
* les consignes actives et appelle ces jumeaux — rien d'autre.
*
* Le front-end (liste_contacts.html, validerFormulaireContact()) appelle cet
* endpoint AVANT l'enregistrement réel (POST/PUT vers contacts.php), pour
* savoir s'il doit afficher le badge IA pendant l'attente de la réponse.
* Un enregistrement qui ne déclenche aucune IA reste donc instantané à
* l'écran, sans badge — exigence explicite de Jean-Marc ("hors de question
* d'afficher systématiquement le badge IA, alors qu'il n'y est pas fait
* appel").
*
* Entrée JSON : { "contexte": "Contacts", "portee": "Création"|"Modification",
* "donnees": { ...valeurs actuelles du formulaire... } }
* Sortie JSON : { "declenche": true|false }
*
* Accès réservé aux utilisateurs identifiés, même principe que contacts.php.
* Toujours POST (l'objet "donnees" peut contenir des champs qui n'ont pas
* leur place dans une query string). Ne touche jamais à $_SESSION au-delà de
* la vérification d'identification, et n'écrit jamais en base.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/consignes_fonctions.php';
require __DIR__ . '/moteur_consignes.php';
// Accès réservé aux utilisateurs identifiés (session ouverte par aspic.php).
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
$corps = json_decode(file_get_contents('php://input'), true);
if (!is_array($corps)) {
$corps = [];
}
$contexte = trim((string)($corps['contexte'] ?? ''));
$portee = trim((string)($corps['portee'] ?? ''));
$donnees = is_array($corps['donnees'] ?? null) ? $corps['donnees'] : [];
if ($contexte === '' || $portee === '') {
http_response_code(400);
echo json_encode(['error' => 'Paramètres "contexte" et "portee" obligatoires']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
$declenche = auMoinsUneConsigneIaSeraitDeclenchee($pdo, $contexte, $portee, $donnees);
} catch (Throwable $e) {
// Repli prudent : en cas de souci imprévu, on répond "pas de badge" plutôt
// que de faire échouer l'écran pour un simple indicateur visuel — même
// philosophie de robustesse que les fonctions IA elles-mêmes (voir
// consignes_fonctions.php).
$declenche = false;
}
echo json_encode(['declenche' => $declenche]);
+417
View File
@@ -0,0 +1,417 @@
<?php
/**
* ASPIC — consignes_fonctions.php
*
* Bibliothèque des fonctions du catalogue ASP_Fonctions (chantier des
* consignes, 6 septembre 2026 — voir Réflexions stratégiques). Chaque
* fonction ci-dessous porte le nom exact enregistré dans
* ASP_Fonctions.FON_NomPHP, et est appelée dynamiquement par le moteur
* générique (voir moteur_consignes.php) — plus aucun appel en dur dans
* contacts.php depuis la version du 6 septembre 2026 (soir).
*
* Convention pour écrire une nouvelle fonction ici :
* - Traitement à UN champ : signature (string $valeur): string — reçoit la
* valeur actuelle du champ, retourne la valeur transformée.
* - Test à UN champ : signature (string $valeur): bool — retourne vrai si la
* valeur respecte la consigne, faux sinon (le message affiché à l'échec
* vient de CNS_MessageErreur en base, jamais de cette fonction).
* - Traitement/Test à PLUSIEURS champs (extension du 7 septembre 2026, voir
* moteur_consignes.php) : quand CNS_Champ contient plusieurs noms de champs
* séparés par une virgule, la fonction reçoit un tableau associatif
* [nom_du_champ => valeur] au lieu d'une simple chaîne — un Traitement
* retourne alors ce même type de tableau (valeurs mises à jour, mêmes clés),
* un Test retourne toujours un simple bool. Voir
* consigne_completer_code_postal() ci-dessous pour un exemple.
* - Test à UN champ avec accès base (8 septembre 2026) : une fonction de Test
* qui a besoin d'interroger la base (ex. vérifier qu'une valeur n'est pas
* déjà utilisée ailleurs) peut déclarer deux paramètres supplémentaires,
* dans cet ordre : (string $valeur, PDO $pdo, ?int $idExclusion): bool — le
* moteur les fournit systématiquement à tout Test à un champ (voir
* moteur_consignes.php), $idExclusion étant l'identifiant de la fiche en
* cours de modification (null en Création). Voir consigne_mail_unique() et
* consigne_portable_unique() ci-dessous, premier cas réel.
* - Jumeau "_seraitDeclenchee" (7 septembre 2026, soir) : toute fonction de
* Traitement qui interroge un service externe (IA) et porte
* FON_VisuelAttenteIA = 'O' DOIT avoir, en plus, une fonction nommée
* "<nom_de_la_fonction>_seraitDeclenchee", signature (array $valeurs): bool
* — répond "est-ce que j'agirais sur ces valeurs", sans jamais rien faire
* d'autre (aucun appel réseau). La vraie fonction appelle ce jumeau EN
* PREMIER (voir consigne_completer_code_postal() ci-dessous) : la condition
* de déclenchement n'existe ainsi qu'à un seul endroit, jamais recopiée ni
* devinée ailleurs (pas même dans ce fichier) — c'est ce jumeau
* qu'interroge consignes_apercu.php pour piloter le badge IA générique côté
* écran, voir ce fichier et moteur_consignes.php
* (auMoinsUneConsigneIaSeraitDeclenchee()).
* Fichier volontairement sans dépendance à la session : chaque fonction ne
* fait que transformer ou tester une ou plusieurs valeurs qu'on lui passe (et,
* depuis le 8 septembre 2026, éventuellement interroger $pdo — voir ci-dessus
* — mais jamais $_SESSION). Deux exceptions à la dépendance base/réseau :
* - Depuis le 7 septembre 2026 (soir) : une fonction de Traitement peut
* interroger un service externe (API d'IA, voir
* consigne_completer_code_postal()) quand la consigne elle-même l'exige
* explicitement, toujours avec un repli silencieux (valeurs inchangées) en
* cas d'échec : un Traitement ne doit jamais faire échouer un
* enregistrement à cause d'un service tiers indisponible.
* - Depuis le 8 septembre 2026 : une fonction de Test à un champ peut
* interroger $pdo, reçu directement du moteur (voir "Test à UN champ avec
* accès base" ci-dessus) — jamais de connexion ouverte ici, toujours celle
* déjà établie par l'appelant (db.php).
*/
require __DIR__ . '/moteur_prompts.php';
// ASP_Fonctions.FON_Nom = 'MAJUSCULE' (Nature=Traitement)
// Met en majuscules la valeur d'un champ, en respectant les caractères
// accentués français (mb_strtoupper avec encodage explicite : un simple
// strtoupper() laisserait "é"/"è"/... inchangés selon la locale du serveur).
function consigne_majuscule(string $valeur): string
{
return mb_strtoupper($valeur, 'UTF-8');
}
// ASP_Fonctions.FON_Nom = 'NON_VIDE' (Nature=Test)
// Vrai si la valeur n'est pas vide une fois débarrassée des espaces
// superflus en début/fin (une chaîne de simples espaces est considérée vide).
function consigne_non_vide(string $valeur): bool
{
return trim($valeur) !== '';
}
// ASP_Fonctions.FON_Nom = 'PREMIERE_LETTRE_MAJUSCULE' (Nature=Traitement)
// "georges" -> "Georges", "JOSEPH" -> "Joseph", "jean-marc" -> "Jean-Marc".
// Tout le reste en minuscules, une majuscule après le début de la chaîne et
// après chaque tiret ou espace (pour les prénoms composés). Volontairement
// pas de ucwords() : cette fonction native de PHP n'est pas fiable en UTF-8
// (elle raisonne en octets, pas en caractères — un prénom commençant par une
// lettre accentuée type "Éric" serait corrompu).
function consigne_premiere_lettre_majuscule(string $valeur): string
{
$valeur = mb_strtolower($valeur, 'UTF-8');
return (string)preg_replace_callback(
'/(^|[\s\-])(\p{L})/u',
function (array $m): string {
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
},
$valeur
);
}
// ASP_Fonctions.FON_Nom = 'MAIL_VALIDE' (Nature=Test), 7 septembre 2026.
// Vrai si la valeur ressemble à une adresse mail syntaxiquement correcte,
// faux sinon — une simple cohérence de saisie ("@" présent, domaine
// plausible), jamais une vérification que la boîte existe réellement.
// Champ facultatif : une valeur vide est considérée conforme ici (le
// caractère obligatoire ou non du champ reste la responsabilité d'une
// consigne NON_VIDE séparée, combinable avec celle-ci sur le même champ).
//
// Volontairement PAS de regex écrite à la main du style "@ suivi de deux
// groupes de lettres séparés par un point" : une adresse valide peut avoir
// un domaine à plus de deux niveaux (ex. toto@tata.ndd.com), un chiffre, un
// tiret... filter_var(FILTER_VALIDATE_EMAIL) est le validateur natif de PHP,
// déjà utilisé ailleurs dans ASPIC pour la même vérification (voir
// prospects_core.php, contrôle sur PSP_Mail) — le reprendre ici garde une
// seule et même définition de "adresse mail valide" dans toute l'application
// plutôt que deux logiques différentes qui pourraient diverger.
function consigne_mail_valide(string $valeur): bool
{
$valeur = trim($valeur);
if ($valeur === '') return true;
return filter_var($valeur, FILTER_VALIDATE_EMAIL) !== false;
}
// ASP_Fonctions.FON_Nom = 'RETIRER_POINTS' (Nature=Traitement), 8 septembre
// 2026. Retire tous les points (".") d'une valeur, sans autre transformation
// — demandée pour un numéro de téléphone saisi avec des points comme
// séparateurs (ex. "02.98.12.34.56" -> "0298123456"). Générique et
// réutilisable tel quel sur n'importe quel champ texte, même principe que
// SANS_CHIFFRE ci-dessus : aucune fonction par champ, juste une ligne dans
// ASP_Consignes par champ concerné (CNT_Tel et/ou CNT_Portable). Volontairement
// limitée aux points, sur la demande telle que formulée par Jean-Marc — à
// étendre (espaces, tirets...) le jour où un besoin réel et concret se
// présentera, pas par anticipation.
function consigne_retirer_points(string $valeur): string
{
return str_replace('.', '', $valeur);
}
// ASP_Fonctions.FON_Nom = 'SANS_CHIFFRE' (Nature=Test), 7 septembre 2026.
// Vrai si la valeur ne contient aucun chiffre, faux sinon — champ facultatif
// (une valeur vide est conforme, même principe que consigne_mail_valide() :
// le caractère obligatoire est la responsabilité d'une consigne NON_VIDE
// séparée, combinable sur le même champ). Générique et réutilisable tel
// quel sur n'importe quel champ texte : Jean-Marc l'a demandée pour Nom,
// Prénom et Ville, ce qui ne demande ici AUCUNE fonction par champ — juste
// trois lignes dans ASP_Consignes (CNT_Nom, CNT_Prenom, CNT_AdrVille)
// pointant toutes vers cette même fonction, chacune avec son propre
// CNS_MessageErreur si on veut un message différent par champ.
function consigne_sans_chiffre(string $valeur): bool
{
return trim($valeur) === '' || !preg_match('/\d/', $valeur);
}
// ASP_Fonctions.FON_Nom = 'CP_DEPUIS_VILLE' (Nature=Traitement, CNS_Champ =
// "CNT_AdrVille,CNT_AdrCP"), 7 septembre 2026 (soir).
// Première consigne "qui va chercher sa réponse avec l'IA" (demande de
// Jean-Marc) : complète le code postal à partir de la ville quand celui-ci
// n'a pas été saisi. Un champ seul ne suffisait pas ici (il faut la ville en
// entrée pour produire le code postal en sortie) — voir l'extension du
// moteur à plusieurs champs dans moteur_consignes.php.
//
// Ne fait RIEN (retourne les valeurs inchangées) si le code postal est déjà
// renseigné — jamais d'écrasement d'une saisie existante, même douteuse —
// ou si la ville est vide (rien à déduire). Sinon interroge le même modèle
// que contact_ia_interprete.php (OPENAI_API_KEY, voir ce fichier pour le
// détail de l'intégration) avec une sortie structurée stricte : le modèle
// renvoie soit un code postal à 5 chiffres qu'il connaît avec une confiance
// raisonnable, soit null s'il n'est pas sûr — jamais un code inventé, même
// principe de prudence que consigne_mail_valide() ci-dessus (mieux vaut
// laisser le champ vide qu'afficher une fausse information). Une ville qui
// couvre plusieurs codes postaux sans qu'aucun ne soit clairement principal
// (ex. arrondissements de Paris/Lyon/Marseille) doit renvoyer null plutôt que
// de deviner — c'est écrit explicitement dans la consigne envoyée au modèle.
//
// Toute défaillance (clé absente, réseau, quota, réponse inattendue) laisse
// toujours CNT_AdrCP inchangé — un Traitement ne doit jamais bloquer
// l'enregistrement d'un contact à cause d'un service externe (voir
// commentaire d'en-tête de moteur_consignes.php) — mais n'est PLUS silencieuse
// pour autant depuis le 8 septembre 2026 (correctif demandé par Jean-Marc,
// constat d'un échec resté invisible sur une ville inconnue) : chaque cas
// d'échec pose désormais un "_avertissement" pour l'écran, et un
// "_realisation" quand un appel a réellement été tenté (voir le corps de la
// fonction ci-dessous) — contrairement
// à un Test, dont l'échec est lui affiché à l'utilisateur.
// Jumeau "_seraitDeclenchee" (voir en-tête de fichier) : réplique à elle
// seule la condition de déclenchement de consigne_completer_code_postal()
// ci-dessous, sans jamais faire le moindre appel réseau. C'est la SEULE
// fonction que consignes_apercu.php interroge pour savoir si le badge IA doit
// apparaître — la vraie fonction l'appelle elle-même en premier, pour que
// cette condition n'existe jamais qu'à cet unique endroit.
function consigne_completer_code_postal_seraitDeclenchee(array $valeurs): bool
{
$ville = trim((string)($valeurs['CNT_AdrVille'] ?? ''));
$codePostal = trim((string)($valeurs['CNT_AdrCP'] ?? ''));
return $codePostal === '' && $ville !== '';
}
function consigne_completer_code_postal(array $valeurs): array
{
if (!consigne_completer_code_postal_seraitDeclenchee($valeurs)) {
return $valeurs;
}
$ville = trim((string)($valeurs['CNT_AdrVille'] ?? ''));
$apiKey = trim((string) getenv('OPENAI_API_KEY'));
if (!$apiKey) {
// Panne de configuration (8 septembre 2026) : jusqu'ici un retour
// totalement silencieux — repéré par Jean-Marc comme le même défaut
// que celui déjà corrigé le 7 septembre pour une ville ambiguë
// ("un constat d'échec doit toujours être visible, jamais un simple
// silence"). Pas de "_realisation" ici : aucun appel réseau n'a
// réellement eu lieu, donc rien à journaliser comme un coût engagé
// (voir en-tête de moteur_consignes.php) — seulement l'avertissement
// écran, pour que l'utilisateur ne reste pas devant un champ vide
// sans explication.
$valeurs['_avertissement'] = "Code postal non déterminé pour « $ville » "
. "(service indisponible). Saisissez le code postal vous-même.";
return $valeurs;
}
// Prompt sorti du code le 11 septembre 2026 — chargé ici (pas reçu de
// l'appelant : cette fonction, comme les autres Traitements à un/plusieurs
// champs, ne reçoit pas $pdo par contrat, voir en-tête de fichier) via sa
// propre connexion, comme le ferait n'importe quel autre endpoint. Un
// échec ici est traité comme la clé API absente ci-dessus : aucun appel
// réseau OpenAI n'a encore été tenté, donc pas de "_realisation" à poser,
// seulement l'avertissement écran.
try {
$promptSysteme = chargerPromptSysteme(getDbConnection(), 'CONTACT_IA_CODE_POSTAL');
} catch (Throwable $e) {
$valeurs['_avertissement'] = "Code postal non déterminé pour « $ville » "
. "(prompt système indisponible). Saisissez le code postal vous-même.";
return $valeurs;
}
$schema = [
'type' => 'object',
'properties' => [
'code_postal' => [
'type' => ['string', 'null'],
'description' => "Code postal français à 5 chiffres correspondant à la ville donnée, uniquement si connu avec une confiance raisonnable. null si la ville est ambiguë, inconnue, introuvable en France, ou couvre plusieurs codes postaux sans qu'un seul soit clairement le plus probable.",
],
],
'required' => ['code_postal'],
'additionalProperties' => false,
];
$corpsRequete = [
'model' => 'gpt-5.6-luna', // même modèle que contact_ia_interprete.php, voir ce fichier.
'messages' => [
['role' => 'system', 'content' => $promptSysteme],
['role' => 'user', 'content' => $ville],
],
'response_format' => [
'type' => 'json_schema',
'json_schema' => ['name' => 'code_postal_ville', 'strict' => true, 'schema' => $schema],
],
];
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpsRequete),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
// Appelée en synchrone pendant l'enregistrement d'un contact (choix
// de Jean-Marc, 7 septembre 2026 : "oui, tel quel" malgré le coût/la
// latence) : délai volontairement court, on préfère abandonner
// (valeurs inchangées) plutôt que de faire attendre longtemps sur un
// enrichissement facultatif.
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$reponseBrute = curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($reponseBrute === false || $codeHttp !== 200) {
// Échec réseau/HTTP (8 septembre 2026) : contrairement au cas
// "clé API absente" ci-dessus, un appel a bien été tenté (et peut
// avoir un coût, selon où l'échec survient côté fournisseur) — donc
// journalisé comme le cas "ville ambiguë" plus bas, sur le même
// principe posé le 7 septembre par Jean-Marc ("un appel fait à l'IA
// [doit laisser] une trace dans les réalisations").
$valeurs['_realisation'] = [
'action' => 'IA_CP',
'commentaire' => "Échec technique de l'appel IA pour la ville « $ville » (HTTP $codeHttp).",
];
$valeurs['_avertissement'] = "Code postal non déterminé pour « $ville » "
. "(service temporairement indisponible). Saisissez le code postal vous-même.";
return $valeurs;
}
$reponseDecodee = json_decode($reponseBrute, true);
$contenuJson = $reponseDecodee['choices'][0]['message']['content'] ?? null;
$extraction = $contenuJson ? json_decode($contenuJson, true) : null;
$codePostalDeduit = $extraction['code_postal'] ?? null;
// Garde-fou défensif (même principe qu'ailleurs dans ASPIC : jamais
// confiance aveugle en un service externe) : un code postal français est
// exactement 5 chiffres, sinon la réponse est ignorée.
if (is_string($codePostalDeduit) && preg_match('/^\d{5}$/', $codePostalDeduit)) {
$valeurs['CNT_AdrCP'] = $codePostalDeduit;
// Journalisation (7 septembre 2026, soir, demande de Jean-Marc après
// son premier essai réel — "indispensable" pour pouvoir relier une
// ligne de coût IA à sa cause) : clé réservée "_realisation", voir
// moteur_consignes.php pour le mécanisme générique. Pas de champ
// 'agent' ici (contrairement au tout premier jet, qui inventait "Mat"
// en dur) : Jean-Marc a fait remarquer, à raison, qu'ASP_Consignes
// porte déjà CNS_Agent — c'est le moteur (executerTraitements(), voir
// moteur_consignes.php) qui l'y lit et l'attribue à la réalisation,
// jamais cette fonction. ACT_Code 'IA_CP' à créer à la main dans
// ASP_Actions (table gérée à la main, voir realisations.php) avant le
// premier passage réel en production.
$valeurs['_realisation'] = [
'action' => 'IA_CP',
'commentaire' => "Code postal $codePostalDeduit déduit de la ville « $ville » par l'IA.",
];
} else {
// Appel IA fait, coût réellement engagé, mais sans réponse
// exploitable — ex. Paris/Lyon/Marseille, découpées en
// arrondissements sans code postal unique, pour lesquelles le modèle
// renvoie sciemment null plutôt que de deviner (voir le prompt
// système ci-dessus). Repéré par Jean-Marc (7 septembre 2026, nuit,
// test sur "Paris") : "il y a un appel fait à l'IA et pas de trace
// dans les réalisations, ce qui n'est pas normal" — un appel qui ne
// trouve rien coûte quand même des tokens, il doit donc apparaître
// au même titre qu'un appel réussi. Même ACT_Code 'IA_CP', un
// commentaire différent suffit à distinguer les deux cas dans
// l'historique, sans demander à Jean-Marc de créer un deuxième code
// dans ASP_Actions.
$valeurs['_realisation'] = [
'action' => 'IA_CP',
'commentaire' => "Aucun code postal déduit pour la ville « $ville » (ambiguë ou inconnue pour l'IA).",
];
// '_avertissement' (nouvelle clé réservée, voir moteur_consignes.php) :
// contrairement à '_realisation' (historique consulté a posteriori),
// ce message est affiché IMMÉDIATEMENT à l'écran — sans lui,
// l'utilisateur ne voit rien du tout et ne sait pas que l'IA a bien
// réfléchi puis sciemment renoncé, plutôt qu'un simple silence.
// Message volontairement impératif (7 septembre 2026, demande de
// Jean-Marc) : le toast qui l'affiche reste à l'écran tant que
// l'utilisateur n'a pas cliqué pour le refermer (voir
// liste_contacts.html/afficherToast()), l'instruction doit donc être
// sans ambiguïté sur ce qu'il reste à faire.
$valeurs['_avertissement'] =
"Code postal non déterminé pour « $ville » (ville ambiguë ou "
. "avec plusieurs codes postaux possibles). Saisissez le code "
. "postal vous-même.";
}
return $valeurs;
}
// ASP_Fonctions.FON_Nom = 'MAIL_UNIQUE' (Nature=Test), 8 septembre 2026.
// Vrai si l'adresse mail n'est pas déjà utilisée par un AUTRE contact actif,
// faux sinon. Champ facultatif, même principe que consigne_mail_valide()
// ci-dessus : une valeur vide est toujours conforme (deux fiches sans mail ne
// sont pas en conflit) — le caractère obligatoire reste la responsabilité
// d'une consigne NON_VIDE séparée, combinable sur le même champ.
//
// Fonction dédiée à CNT_Mail plutôt qu'un test générique "valeur unique"
// paramétrable par nom de champ (demande explicite de Jean-Marc, 8 septembre
// 2026) : contrairement à SANS_CHIFFRE ci-dessus, volontairement générique
// parce que ça a du sens sur Nom/Prénom/Ville indifféremment, une contrainte
// d'unicité n'a de sens QUE sur certains champs précis — l'ouvrir à un
// paramétrage libre depuis ASP_Consignes exposerait un futur opérateur du
// catalogue à l'appliquer par erreur à un champ comme Prénom, où deux
// contacts homonymes sont parfaitement normaux. Voir consigne_portable_unique()
// ci-dessous, jumelle sur CNT_Portable — toutes deux appuyées sur le même
// utilitaire privé valeurDejaUtilisee() pour ne pas dupliquer la requête SQL.
function consigne_mail_unique(string $valeur, PDO $pdo, ?int $idExclusion = null): bool
{
return !valeurDejaUtilisee($pdo, 'CNT_Mail', $valeur, $idExclusion);
}
// ASP_Fonctions.FON_Nom = 'PORTABLE_UNIQUE' (Nature=Test), 8 septembre 2026.
// Même principe que consigne_mail_unique() ci-dessus, appliqué à
// CNT_Portable — fonction séparée plutôt qu'un simple paramétrage de la même
// fonction sur un autre champ, pour la même raison (voir commentaire de
// consigne_mail_unique()).
function consigne_portable_unique(string $valeur, PDO $pdo, ?int $idExclusion = null): bool
{
return !valeurDejaUtilisee($pdo, 'CNT_Portable', $valeur, $idExclusion);
}
// Utilitaire privé partagé par consigne_mail_unique() et
// consigne_portable_unique() ci-dessus — pas une fonction du catalogue
// (aucun ASP_Fonctions.FON_NomPHP ne pointe dessus, jamais appelée depuis
// ASP_Consignes) : factorise uniquement la requête SQL commune aux deux.
// $champ vient toujours d'une constante écrite en dur dans l'une des deux
// fonctions ci-dessus, jamais d'une donnée utilisateur — pas de risque
// d'injection SQL malgré l'interpolation dans le nom de colonne.
// $idExclusion (identifiant du contact en cours de modification, voir
// moteur_consignes.php) permet à une fiche de conserver sa propre valeur sans
// se bloquer elle-même ; toujours null en Création, où rien n'existe encore à
// exclure.
function valeurDejaUtilisee(PDO $pdo, string $champ, string $valeur, ?int $idExclusion): bool
{
$valeur = trim($valeur);
if ($valeur === '') return false;
$sql = "SELECT 1 FROM ASP_Contacts WHERE $champ = ? AND CNT_Actif = 1";
$params = [$valeur];
if ($idExclusion !== null) {
$sql .= ' AND CNT_Incr != ?';
$params[] = $idExclusion;
}
$sql .= ' LIMIT 1';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return (bool)$stmt->fetchColumn();
}
+349
View File
@@ -0,0 +1,349 @@
<?php
/**
* ASPIC — contact_ia_interprete.php
*
* Outil de mise au point interne (4 septembre 2026, demande explicite de
* Jean-Marc) : PAS un endpoint destiné aux futurs utilisateurs à ce stade.
* Prolonge la banque de dictées de dictees_test.php/micro_test.js — jusqu'ici
* la dictée était seulement enregistrée brute, sans aucune interprétation
* (voir réflexions stratégiques, section 12 : "constituer d'abord un corpus
* réel avant de concevoir les règles"). Avec ce fichier, on franchit l'étape
* suivante pour un seul périmètre volontairement restreint — la création
* d'un contact — en s'appuyant sur un LLM à sortie structurée plutôt que sur
* des règles écrites à la main (rejetées pour ce problème dans les
* réflexions stratégiques : trop de variabilité dans les formulations
* réelles pour un empilement de règles/regex).
*
* Reçoit un texte dicté libre (POST, corps JSON {"texte": "..."}) et renvoie
* les champs de ASP_Contacts qu'un modèle OpenAI (function calling / sortie
* structurée JSON) a pu en extraire — laissés à null quand rien de fiable
* n'a été trouvé, jamais devinés. Ce fichier NE CRÉE JAMAIS de contact
* lui-même : c'est le rôle de contacts.php (déjà en production), appelé
* séparément par le front-end une fois que Jean-Marc a vérifié/complété les
* champs à l'écran — voir contact_ia_test.html/.js.
*
* Choix d'architecture :
* - Un seul appel LLM par interprétation, pas de session/état conservé ici.
* - Les listes de référence (ASP_Statuts, ASP_Professions) sont relues à
* chaque appel et injectées dans le prompt — jamais recopiées en dur dans
* ce fichier, même principe que AGN_Variantes/AGN_Defaut pour les agents
* vocaux (piloté en base, pas figé dans le code).
* - CNT_Statut doit être un STA_Code réellement existant (ou null) : imposé
* par une énumération dans le schéma JSON transmis au modèle, jamais une
* valeur inventée.
* - CNT_CodeProfession (clé vers ASP_Professions, liste OUVERTE contrairement
* à ASP_Statuts — voir professions.php) ne peut pas être imposé par
* énumération de la même façon : le modèle renvoie soit l'identifiant
* d'une profession existante si elle correspond clairement, soit un
* libellé de profession nouvelle en toutes lettres si aucune ne
* correspond — c'est au front-end de créer cette profession (POST
* professions.php, déjà utilisé ailleurs pour "+ Nouvelle profession…")
* avant de créer le contact, jamais à ce fichier.
* - Tout ce qui est dit et ne correspond à aucun champ structuré n'est pas
* perdu : renvoyé dans CNT_Notes (résumé concis, pas nécessairement mot
* pour mot) — cohérent avec la règle déjà en place ailleurs dans le
* projet ("aucun essai raté supprimé", rien n'est jeté).
*
* Clé d'API : lue via getenv('OPENAI_API_KEY'), jamais en dur dans le code —
* même convention que DB_USER/DB_PASS dans db.php (variable d'environnement
* posée côté docker-compose sur le VPS, ajoutée par Jean-Marc le 4 septembre
* 2026). Si absente, l'endpoint répond 500 avec un message explicite plutôt
* que d'échouer silencieusement.
*
* ⚠️ Point non vérifié en conditions réelles : cette session n'a pas la main
* pour exécuter un vrai appel à l'API OpenAI (pas de clé disponible ici,
* jamais transmise — voir échange avec Jean-Marc). L'appel ci-dessous suit
* la documentation OpenAI (endpoint Chat Completions, response_format en
* json_schema strict) au 4 septembre 2026, mais mérite un premier essai réel
* pour confirmer que la forme de la requête/réponse n'a pas divergé entre
* temps. Si le premier essai échoue, le message d'erreur renvoyé par
* OpenAI est transmis tel quel au front-end (voir gérerErreurOpenAI) pour
* pouvoir corriger vite sans deviner.
*
* Modèle utilisé : constante OPENAI_MODEL ci-dessous, volontairement isolée
* pour pouvoir monter en gamme (gpt-5.6-terra, plus capable mais plus cher —
* voir comparatif tarifaire du 4 septembre) sans toucher au reste du fichier
* si la qualité d'extraction sur gpt-5.6-luna se révèle insuffisante à
* l'usage.
*
* ⚠️ Session utilisateur : accès réservé aux utilisateurs identifiés
* (`$_SESSION['USR_Code']`, ouverte par aspic.php), même principe que les
* autres endpoints (dictees_test.php, todolist.php...).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/moteur_prompts.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
const OPENAI_MODEL = 'gpt-5.6-luna';
const OPENAI_CHAT_URL = 'https://api.openai.com/v1/chat/completions';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$texte = trim((string) ($data['texte'] ?? ''));
if ($texte === '') {
http_response_code(400);
echo json_encode(['error' => 'Rien à interpréter']);
exit;
}
// trim() : un espace ou un retour à la ligne resté collé à la valeur en la
// collant dans docker-compose/.env (très facile en copiant-collant depuis le
// navigateur) suffit à faire échouer l'authentification côté OpenAI avec un
// message d'erreur qui ne le laisse pas deviner (la clé "a l'air" correcte).
$apiKey = trim((string) getenv('OPENAI_API_KEY'));
if (!$apiKey) {
http_response_code(500);
echo json_encode(['error' => "Clé OPENAI_API_KEY absente côté serveur — à ajouter dans l'environnement du conteneur avant de pouvoir tester."]);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
// -------- Listes de référence, relues à chaque appel (voir en-tête) --------
try {
$statuts = $pdo->query('SELECT STA_Code, STA_Libellé FROM ASP_Statuts ORDER BY STA_Incr ASC')->fetchAll();
$professions = $pdo->query('SELECT PRF_Incr, PRF_Libelle FROM ASP_Professions ORDER BY PRF_Libelle ASC')->fetchAll();
// Ajout du 4 septembre 2026 (soir), demande de Jean-Marc : deux champs de
// plus à interpréter, "à qui s'adresse la dictée" et "quelles actions
// sont demandées" — en réutilisant deux tables déjà existantes plutôt
// qu'en inventer de nouvelles : ASP_Agents (déjà lue par agents.php pour
// le vrai pilotage vocal, mêmes AGN_Variantes tolérées ici) et
// ASP_Actions (déjà utilisée par realisations.php pour journaliser une
// action faite sur un contact — même liste de référence, réutilisée ici
// en amont, comme intention plutôt que comme réalisation déjà faite).
// Pas de AGN_Defaut ici : Jean-Marc a précisé (4 septembre 2026, soir)
// que le choix de l'assistant par défaut quand personne n'est nommément
// cité n'est PAS la responsabilité de ce fichier — c'est le traitement
// déclenché par n8n, en aval, qui s'en charge au moment de transformer
// une proposition validée (ASP_PropositionsContact) en post-it réel. Ce
// fichier reste volontairement passif : il ne renvoie que ce qui a été
// réellement dit (assistant_id reste null si personne n'est cité), sans
// jamais anticiper une décision qui appartient à l'étage suivant.
// AGN_Nom renommée AGN_Prenom en base (7 septembre 2026, nuit).
$agents = $pdo->query('SELECT AGN_Incr, AGN_Prenom, 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_Prenom'] .
($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)';
// Texte sorti du code le 11 septembre 2026 — voir moteur_prompts.php pour le
// texte par défaut/la table ASP_Prompts, bo_prompts.html pour l'édition.
try {
$promptSysteme = chargerPromptSysteme($pdo, 'CONTACT_IA_INTERPRETE', [
'{{LISTE_STATUTS}}' => $listeStatuts,
'{{LISTE_PROFESSIONS}}' => $listeProfessions,
'{{LISTE_AGENTS}}' => $listeAgents,
'{{LISTE_ACTIONS}}' => $listeActions,
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Prompt système indisponible : ' . $e->getMessage()]);
exit;
}
// -------- Appel OpenAI --------
$corpsRequete = [
'model' => OPENAI_MODEL,
'messages' => [
['role' => 'system', 'content' => $promptSysteme],
['role' => 'user', 'content' => $texte],
],
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'extraction_contact',
'strict' => true,
'schema' => $schema,
],
],
// Ni temperature ni max_completion_tokens fixés volontairement : la
// famille de modèles gpt-5.6 a changé plusieurs fois la prise en charge
// de ces paramètres selon la variante — mieux vaut s'en tenir aux
// valeurs par défaut tant que le premier essai réel n'a pas confirmé ce
// qui est accepté, plutôt que de risquer une requête rejetée pour un
// paramètre mal supporté.
];
$ch = curl_init(OPENAI_CHAT_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpsRequete),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$reponseBrute = curl_exec($ch);
$erreurCurl = curl_error($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($reponseBrute === false) {
http_response_code(502);
echo json_encode(['error' => "Appel à l'API OpenAI impossible : " . $erreurCurl]);
exit;
}
$reponseDecodee = json_decode($reponseBrute, true);
if ($codeHttp !== 200) {
http_response_code(502);
echo json_encode([
'error' => "L'API OpenAI a renvoyé une erreur (HTTP $codeHttp) : " .
($reponseDecodee['error']['message'] ?? $reponseBrute),
]);
exit;
}
$contenuJson = $reponseDecodee['choices'][0]['message']['content'] ?? null;
if (!$contenuJson) {
http_response_code(502);
echo json_encode(['error' => "Réponse OpenAI inattendue (pas de contenu) : " . $reponseBrute]);
exit;
}
$extraction = json_decode($contenuJson, true);
if ($extraction === null) {
http_response_code(502);
echo json_encode(['error' => "Le contenu renvoyé par OpenAI n'est pas un JSON valide : " . $contenuJson]);
exit;
}
// Garde-fous défensifs : le mode strict d'OpenAI est censé garantir des
// valeurs valides (CNT_Statut, assistant_id, codes d'action), mais on ne
// fait jamais confiance aveuglément à un service externe pour la suite
// (voir contacts.php, même principe déjà appliqué à CNT_Statut).
if (!empty($extraction['champs']['CNT_Statut']) && !in_array($extraction['champs']['CNT_Statut'], $codesStatutsValides, true)) {
$extraction['champs']['CNT_Statut'] = null;
}
if (!empty($extraction['assistant_id']) && !in_array((int) $extraction['assistant_id'], $idsAgentsValides, true)) {
$extraction['assistant_id'] = null;
}
foreach ($extraction['actions'] ?? [] as &$action) {
if (!empty($action['action_existante_code']) && !in_array($action['action_existante_code'], $codesActionsValides, true)) {
$action['action_existante_code'] = null;
}
}
unset($action);
echo json_encode([
'extraction' => $extraction,
'statuts' => $statuts,
'professions' => $professions,
'agents' => $agents,
'actionsDisponibles' => $actionsDisponibles,
]);
+849
View File
@@ -0,0 +1,849 @@
/**
* 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);
// AGN_Nom renommée AGN_Prenom en base (7 septembre 2026, nuit) — voir
// agents.php, qui renvoie désormais cette clé sous son nouveau nom.
option.textContent = a.AGN_Prenom + (a.AGN_Fonction ? " (" + a.AGN_Fonction + ")" : "");
selectAssistant.appendChild(option);
});
if(!agents || !agents.length){
const optionVide = document.createElement("option");
optionVide.value = "";
optionVide.textContent = "(aucun assistant en base)";
optionVide.disabled = true;
selectAssistant.appendChild(optionVide);
}
if(idChoisi){ selectAssistant.value = String(idChoisi); selectAssistant.classList.add("interprete"); }
}
// Trois zones de texte libre plutôt qu'une liste dynamique existant/nouveau
// par ligne (voir la construction du DOM plus haut, même justification).
// Pré-remplit les 3 premières actions extraites (libellé d'une action
// existante résolu via actionsDispo, ou le libellé libre proposé par le
// LLM) ; au-delà de 3, les actions supplémentaires ne sont plus éditables
// ici mais restent intactes dans PCT_Extraction (rien n'est perdu pour
// l'analyse, seule la validation se limite à 3 — cohérent avec l'objectif
// de cette phase : un essai vocal rapide, pas un formulaire exhaustif).
// Libellé fixe utilisé par le prompt (contact_ia_interprete.php) pour
// l'action "créer/ajouter le contact" quand elle est demandée — le prompt
// demande déjà au LLM de la placer en premier dans le tableau, ce filet
// la remonte en tête côté client si jamais ce n'était pas le cas (demande
// de Jean-Marc, 4 septembre 2026 soir : cette action doit apparaître en
// premier dans la liste, il teste justement la création de contacts).
const LIBELLE_ACTION_CREATION = "Ajouter le contact";
function remplirActions(actionsDispo, actionsExtraites){
const libellesParCode = {};
(actionsDispo || []).forEach(function(a){ libellesParCode[a.ACT_Code] = a.ACT_Libelle; });
const textes = (actionsExtraites || [])
.map(function(a){
return a.action_existante_code
? (libellesParCode[a.action_existante_code] || a.action_existante_code)
: (a.action_nouvelle_libelle || "");
})
.filter(function(t){ return t; })
.sort(function(a, b){
const aCreation = a === LIBELLE_ACTION_CREATION;
const bCreation = b === LIBELLE_ACTION_CREATION;
return aCreation === bCreation ? 0 : (aCreation ? -1 : 1);
});
champsActions.forEach(function(champ, i){
if(textes[i]){ champ.value = textes[i]; champ.classList.add("interprete"); }
});
}
function collecterChamps(){
const champs = {};
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){
champs[el.dataset.champ] = el.value.trim();
});
return champs;
}
function collecterProfessionValidee(){
if(selectProfession.value === "__nouvelle__"){
return {profession_existante_id: null, profession_nouvelle_libelle: champNouvelleProfession.value.trim() || null};
}
if(selectProfession.value){
return {profession_existante_id: parseInt(selectProfession.value, 10), profession_nouvelle_libelle: null};
}
return {profession_existante_id: null, profession_nouvelle_libelle: null};
}
// Renvoie un simple tableau de textes (plus les objets
// {action_existante_code, action_nouvelle_libelle} d'avant, devenus sans
// objet avec la saisie libre) — voir remarque sur PCT_Valide dans
// contact_ia_valide.php au sujet de ce changement de forme.
function collecterActionsValidees(){
return champsActions
.map(function(champ){ return champ.value.trim(); })
.filter(function(v){ return v; });
}
// Appelée automatiquement quand le micro s'arrête proprement (voir
// l'événement "end" de la reconnaissance, plus bas) — plus de bouton
// "Interpréter" à cliquer (demande de Jean-Marc, 4 septembre 2026 soir).
// Le statut "Interprétation en cours…" est affiché dans la même zone que
// la dictée brute et les erreurs, comme une ligne de terminal parmi
// d'autres, puis retiré une fois la réponse arrivée (succès ou échec).
function lancerInterpretation(){
if(!texteComplet) return;
const ligneStatut = document.createElement("div");
ligneStatut.className = "en-cours";
ligneStatut.textContent = "Interprétation en cours…";
zoneLignes.appendChild(ligneStatut);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
fetch("contact_ia_interprete.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texte: texteComplet})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(corps){
ligneStatut.remove();
derniereExtraction = corps.extraction;
viderFormulaire();
remplirChampsTexte(corps.extraction.champs);
remplirStatuts(corps.statuts, corps.extraction.champs.CNT_Statut);
remplirProfessions(corps.professions, corps.extraction.profession_existante_id, corps.extraction.profession_nouvelle_libelle);
remplirAssistant(corps.agents, corps.extraction.assistant_id);
remplirActions(corps.actionsDisponibles, corps.extraction.actions);
zoneResultat.classList.add("visible");
zoneResultat.scrollIntoView({behavior:"smooth", block:"start"});
}).catch(function(erreur){
ligneStatut.remove();
afficherErreur("Échec de l'interprétation : " + erreur.message);
});
}
function brancherEvenements(){
selectProfession.addEventListener("change", function(){
blocNouvelleProfession.style.display = (selectProfession.value === "__nouvelle__") ? "flex" : "none";
});
boutonValider.addEventListener("click", function(){
const champs = collecterChamps();
if(!champs.CNT_Nom){
messageCreation.className = "ko";
messageCreation.textContent = "Le nom est obligatoire.";
return;
}
boutonValider.disabled = true;
boutonValider.textContent = "Enregistrement en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
const valide = Object.assign(
{champs: champs},
collecterProfessionValidee(),
{
assistant_id: selectAssistant.value ? parseInt(selectAssistant.value, 10) : null,
actions: collecterActionsValidees()
}
);
fetch("contact_ia_valide.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texteBrut: texteComplet, extraction: derniereExtraction, valide: valide})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(){
messageCreation.className = "ok";
messageCreation.textContent = "Essai enregistré (aucun contact réel créé) — merci !";
// Le panneau NE se referme PLUS après Valider (demande de Jean-Marc,
// 4 septembre 2026 soir : il enchaîne plusieurs dictées à la suite,
// sortir de la fenêtre à chaque fois n'était pas pratique). Il reste
// ouvert, remis à zéro et prêt pour la dictée suivante — Échap reste
// le seul moyen de le refermer, quand il n'y a plus rien à saisir.
//
// Le micro se rouvre aussi automatiquement (demande de Jean-Marc,
// encore le même soir : "on revient en écran vierge mais il ne se
// passe rien, je pense que le micro est fermé" — exact, il l'était).
// Sans ça, revenir à un écran vierge sans micro actif ne servait à
// rien pour enchaîner. Le garde-fou !ecouteVoulue évite de rappeler
// reconnaissance.start() si Jean-Marc a déjà recliqué le micro
// pendant les 1,8s d'affichage de la confirmation (start() sur une
// reconnaissance déjà démarrée lève une erreur).
setTimeout(function(){
reinitialiserPanneau();
boutonValider.disabled = false;
boutonValider.textContent = "Valider";
if(!ecouteVoulue) demarrerEcoute();
}, 1800);
}).catch(function(erreur){
messageCreation.className = "ko";
messageCreation.textContent = "Échec de l'enregistrement : " + erreur.message;
boutonValider.disabled = false;
boutonValider.textContent = "Valider";
});
});
}
// -------- Micro (#micro-desk) --------
let dernierDemarrageHorodatage = 0;
let cyclesRapidesConsecutifs = 0;
const SEUIL_CYCLE_RAPIDE_MS = 800;
const MAX_CYCLES_RAPIDES = 5;
function passerEnEcoute(){
boutonMicro.classList.add("ecoute");
boutonMicro.title = "Le micro est ouvert (interprétation IA de contact — outil de mise au point)";
ouvrirPanneau();
demarrerSequencePrompt();
reinitialiserMinuteurInactivite();
}
function revenirAuRepos(){
ecouteVoulue = false;
arreterMinuteurInactivite();
arreterMinuteurSilence();
boutonMicro.classList.remove("ecoute");
boutonMicro.title = "Le micro est inactif";
// Le panneau reste visible volontairement (voir en-tête) : seule
// l'écoute s'arrête, pour laisser Jean-Marc relire/corriger/valider.
}
// Factorisé pour être appelable aussi bien depuis le clic sur le micro que
// depuis la reprise automatique après un "Valider" réussi (voir
// brancherEvenements) — même logique, même gestion d'erreur.
function demarrerEcoute(){
ecouteVoulue = true;
erreurBloquante = false;
try{
reconnaissance.start();
}catch(erreur){
ecouteVoulue = false;
ouvrirPanneau();
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
}
}
boutonMicro.addEventListener("click", function(){
if(ecouteVoulue){
ecouteVoulue = false;
reconnaissance.stop();
return;
}
demarrerEcoute();
});
reconnaissance.addEventListener("start", function(){
dernierDemarrageHorodatage = Date.now();
passerEnEcoute();
});
reconnaissance.addEventListener("end", function(){
if(ecouteVoulue && !erreurBloquante){
const ecoule = Date.now() - dernierDemarrageHorodatage;
cyclesRapidesConsecutifs = (ecoule < SEUIL_CYCLE_RAPIDE_MS) ? (cyclesRapidesConsecutifs + 1) : 0;
if(cyclesRapidesConsecutifs >= MAX_CYCLES_RAPIDES){
erreurBloquante = true;
revenirAuRepos();
afficherErreur("Le micro redémarre en boucle anormalement vite — arrêt automatique. Réessaie dans quelques secondes.");
return;
}
try{
reconnaissance.start();
}catch(erreur){
revenirAuRepos();
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
}
return;
}
// Arrêt propre (clic volontaire de Jean-Marc, ecouteVoulue déjà à false,
// pas une erreur bloquante) : c'est le signal pour lancer l'interprétation
// automatiquement, plus de bouton dédié (voir en-tête et lancerInterpretation).
const arretPropre = !erreurBloquante;
revenirAuRepos();
if(arretPropre) lancerInterpretation();
});
reconnaissance.addEventListener("result", function(evenement){
const dernier = evenement.results[evenement.results.length - 1];
const texte = dernier[0].transcript.trim();
if(!texte) return;
// Tout résultat (intermédiaire ou final) repousse l'arrêt par silence :
// Jean-Marc parle encore, ou vient de parler. Voir DUREE_SILENCE_ARRET_MS.
reinitialiserMinuteurSilence();
if(dernier.isFinal){
figerLigne(texte);
reinitialiserMinuteurInactivite();
} else {
afficherEnCours(texte);
}
});
reconnaissance.addEventListener("error", function(evenement){
const messagesBloquants = {
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"audio-capture": "Aucun micro détecté."
};
if(messagesBloquants[evenement.error]){
erreurBloquante = true;
ouvrirPanneau();
afficherErreur(messagesBloquants[evenement.error]);
}
});
})();
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — Test IA création de contact</title>
<link rel="icon" href="mevelia-icone.png">
<style>
/* contact_ia_test.html — 4 septembre 2026
Outil de mise au point interne (PAS un écran utilisateur), voir
contact_ia_interprete.php pour le détail du choix d'architecture.
Page autonome, volontairement séparée d'ecran_fond.html/micro_test.js :
la collecte brute de dictées (banque de corpus, table ASP_DicteesTest)
continue de tourner sans interférence — cette page est un banc d'essai
à part pour la première étape d'interprétation (LLM à sortie
structurée), qu'on peut abandonner ou retravailler sans rien défaire
ailleurs.
Look "terminal" repris de la fenêtre de micro_test.js (fond noir, police
monospace) — c'est ce rendu qui a plu à Jean-Marc : on est "dans le cœur
de la bête", pas dans un écran utilisateur poli, ce qui prépare
psychologiquement à un outil moins accompagné qu'un vrai formulaire.
Correction du 4 septembre 2026 (soir), Jean-Marc : « Valider » ne crée
surtout PAS de contact réel. Ça enregistre seulement l'essai (texte brut
+ interprétation du LLM + correction éventuelle) dans une table à part
(ASP_PropositionsContact, via contact_ia_valide.php), en vue d'une
évolution où chaque instruction vocale validée donnera naissance à un
post-it pour un assistant — pas encore câblé, ASP_ToDoList ne sachant
pas encore attribuer une tâche à un assistant (voir "reste à faire" du
journal de développement). */
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;background:#111;color:#ddd;
font-family:"Segoe UI","Arial",sans-serif;}
#page{max-width:720px;margin:0 auto;padding:24px 16px 60px;}
h1{font-size:16px;color:#888;font-weight:normal;margin:0 0 4px;}
.sous-titre{font-size:12px;color:#666;margin:0 0 20px;}
/* -------- Terminal (dictée brute) -------- */
#terminal{background:#000;border:1px solid #333;border-radius:6px;
font-family:"Courier New",Consolas,monospace;overflow:hidden;
margin-bottom:18px;}
#terminal .entete{color:#888;font-size:11px;padding:6px 10px;
border-bottom:1px solid #222;}
#terminal .controles{display:flex;align-items:center;gap:8px;
padding:8px 10px;border-bottom:1px solid #222;flex-wrap:wrap;}
#terminal .controles button{font:inherit;font-size:13px;
background:#1a1a1a;color:#ccc;border:1px solid #444;border-radius:4px;
padding:6px 14px;cursor:pointer;}
#terminal .controles button.actif{background:#2a4d2a;color:#bdf7bd;
border-color:#3a6d3a;}
#terminal .controles button:disabled{opacity:.4;cursor:default;}
#terminal .controles .etat{margin-left:auto;color:#777;font-size:11px;}
#terminal .lignes{min-height:80px;max-height:260px;overflow-y:auto;
padding:10px;color:#fff;font-size:14px;line-height:1.6;
white-space:pre-wrap;}
#terminal .lignes .en-cours{color:#8a8a8a;font-style:italic;}
#terminal .lignes .erreur{color:#e14a5c;}
/* -------- Formulaire de champs structurés -------- */
#resultat{display:none;}
#resultat.visible{display:block;}
#resultat .bloc{background:#1a1a1a;border:1px solid #333;border-radius:6px;
padding:14px 16px;margin-bottom:14px;}
#resultat .bloc h2{font-size:12px;color:#888;font-weight:normal;
text-transform:uppercase;letter-spacing:.05em;margin:0 0 12px;}
.champ{margin-bottom:10px;}
.champ label{display:block;font-size:11px;color:#999;margin-bottom:3px;}
.champ input,.champ select,.champ textarea{width:100%;font:inherit;
font-size:13px;background:#0d0d0d;color:#eee;border:1px solid #444;
border-radius:4px;padding:6px 8px;}
.champ input.interprete,.champ select.interprete{border-color:#3a6d3a;}
.champ textarea{min-height:60px;resize:vertical;font-family:inherit;}
.grille2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
#bouton-creer{font:inherit;font-size:14px;background:#2a4d2a;color:#bdf7bd;
border:1px solid #3a6d3a;border-radius:5px;padding:10px 20px;
cursor:pointer;}
#bouton-creer:disabled{opacity:.4;cursor:default;}
#message-creation{margin-top:10px;font-size:13px;}
#message-creation.ok{color:#7be07b;}
#message-creation.ko{color:#e14a5c;}
</style>
</head>
<body>
<div id="page">
<h1>Création de contact — essai d'interprétation IA</h1>
<p class="sous-titre">Outil de mise au point interne, pas un écran utilisateur. Dicte librement comme si tu créais un contact, puis clique sur « Interpréter ».</p>
<div id="terminal">
<div class="entete">Dictée brute</div>
<div class="controles">
<button type="button" id="bouton-micro">🎤 Micro</button>
<button type="button" id="bouton-interpreter" disabled>Interpréter →</button>
<button type="button" id="bouton-reinitialiser">Réinitialiser</button>
<span class="etat" id="etat-micro"></span>
</div>
<div class="lignes" id="zone-lignes"></div>
</div>
<div id="resultat">
<div class="bloc">
<h2>Identité</h2>
<div class="grille2">
<div class="champ"><label for="f-nom">Nom</label><input id="f-nom" data-champ="CNT_Nom"></div>
<div class="champ"><label for="f-prenom">Prénom</label><input id="f-prenom" data-champ="CNT_Prenom"></div>
</div>
<div class="grille2">
<div class="champ"><label for="f-statut">Statut</label>
<select id="f-statut" data-champ="CNT_Statut"><option value="">—</option></select>
</div>
<div class="champ"><label for="f-profession">Profession</label>
<select id="f-profession"><option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option></select>
</div>
</div>
<div class="champ" id="bloc-nouvelle-profession" style="display:none;">
<label for="f-profession-nouvelle">Libellé de la nouvelle profession</label>
<input id="f-profession-nouvelle">
</div>
</div>
<div class="bloc">
<h2>Société</h2>
<div class="grille2">
<div class="champ"><label for="f-societe">Société</label><input id="f-societe" data-champ="CNT_Société"></div>
<div class="champ"><label for="f-service">Service</label><input id="f-service" data-champ="CNT_Service"></div>
</div>
<div class="champ"><label for="f-fonction">Fonction</label><input id="f-fonction" data-champ="CNT_Fonction"></div>
<div class="champ"><label for="f-siret">SIRET</label><input id="f-siret" data-champ="CNT_SIRET"></div>
</div>
<div class="bloc">
<h2>Adresse</h2>
<div class="champ"><label for="f-adr1">Ligne 1</label><input id="f-adr1" data-champ="CNT_AdrLig1"></div>
<div class="champ"><label for="f-adr2">Ligne 2</label><input id="f-adr2" data-champ="CNT_AdrLig2"></div>
<div class="champ"><label for="f-adr3">Ligne 3</label><input id="f-adr3" data-champ="CNT_AdrLig3"></div>
<div class="grille2">
<div class="champ"><label for="f-cp">Code postal</label><input id="f-cp" data-champ="CNT_AdrCP"></div>
<div class="champ"><label for="f-ville">Ville</label><input id="f-ville" data-champ="CNT_AdrVille"></div>
</div>
</div>
<div class="bloc">
<h2>Coordonnées</h2>
<div class="grille2">
<div class="champ"><label for="f-tel">Téléphone</label><input id="f-tel" data-champ="CNT_Tel"></div>
<div class="champ"><label for="f-portable">Portable</label><input id="f-portable" data-champ="CNT_Portable"></div>
</div>
<div class="champ"><label for="f-mail">Mail</label><input id="f-mail" data-champ="CNT_Mail"></div>
</div>
<div class="bloc">
<h2>Notes (contexte non rattaché à un champ précis)</h2>
<div class="champ"><textarea id="f-notes" data-champ="CNT_Notes"></textarea></div>
</div>
<button type="button" id="bouton-creer">Valider</button>
<p class="sous-titre">« Valider » n'enregistre PAS de contact réel — seulement cet essai (brut + interprétation + ta correction) pour analyse.</p>
<div id="message-creation"></div>
</div>
</div>
<script src="contact_ia_test.js"></script>
</body>
</html>
+364
View File
@@ -0,0 +1,364 @@
/**
* ASPIC — contact_ia_test.js
*
* Logique de contact_ia_test.html — outil de mise au point interne
* (4 septembre 2026), voir l'en-tête de contact_ia_interprete.php pour le
* contexte et les choix d'architecture.
*
* Reprend, pour la partie micro/reconnaissance vocale, le squelette déjà
* éprouvé de micro_test.js (redémarrage silencieux du moteur, coupe-circuit
* anti-boucle, coupure après inactivité) — volontairement dupliqué plutôt
* que factorisé : ce fichier doit pouvoir être abandonné/retravaillé sans
* aucun risque de toucher à micro_test.js, qui continue d'alimenter la
* banque de dictées brutes en production. Contrairement à micro_test.js,
* pas de "mode adresse" ici : si un champ dicté (email, orthographe d'un nom
* rare...) est mal capté, la correction se fait directement dans le champ
* du formulaire affiché après interprétation — c'est précisément
* l'échappatoire "passer en saisie" actée avec Jean-Marc le 4 septembre
* (réflexions stratégiques, section 12) : chaque champ du formulaire EST
* cette échappatoire, pas besoin d'un mécanisme dédié en plus.
*/
(function(){
"use strict";
const boutonMicro = document.getElementById("bouton-micro");
const boutonInterpreter = document.getElementById("bouton-interpreter");
const boutonReinitialiser = document.getElementById("bouton-reinitialiser");
const etatMicro = document.getElementById("etat-micro");
const zoneLignes = document.getElementById("zone-lignes");
const zoneResultat = document.getElementById("resultat");
const boutonCreer = document.getElementById("bouton-creer");
const messageCreation = document.getElementById("message-creation");
const selectStatut = document.getElementById("f-statut");
const selectProfession = document.getElementById("f-profession");
const blocNouvelleProfession = document.getElementById("bloc-nouvelle-profession");
const champNouvelleProfession = document.getElementById("f-profession-nouvelle");
// -------- Reconnaissance vocale (squelette repris de micro_test.js) --------
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
let reconnaissance = null;
let ecouteVoulue = false;
let erreurBloquante = false;
let ligneEnCours = null;
let texteComplet = ""; // buffer de toute la dictée depuis le dernier "Réinitialiser"
let derniereExtraction = null; // dernière réponse de contact_ia_interprete.php, gardée pour comparaison avec ce que Jean-Marc valide ensuite
const DUREE_INACTIVITE_MS = 10 * 60 * 1000; // 10 minutes, même prudence que micro_test.js
let minuteurInactivite = null;
function reinitialiserMinuteurInactivite(){
if(minuteurInactivite) clearTimeout(minuteurInactivite);
minuteurInactivite = setTimeout(function(){
if(!ecouteVoulue) return;
ecouteVoulue = false;
reconnaissance.stop();
}, DUREE_INACTIVITE_MS);
}
function arreterMinuteurInactivite(){
if(minuteurInactivite){ clearTimeout(minuteurInactivite); minuteurInactivite = null; }
}
function afficherErreur(texte){
const div = document.createElement("div");
div.className = "erreur";
div.textContent = texte;
zoneLignes.appendChild(div);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function afficherEnCours(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
ligneEnCours.className = "en-cours";
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.textContent = texte;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function figerLigne(texte){
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.className = "";
ligneEnCours.textContent = texte;
ligneEnCours = null;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
texteComplet = (texteComplet + " " + texte).trim();
boutonInterpreter.disabled = (texteComplet === "");
}
if(!ReconnaissanceVocale){
boutonMicro.disabled = true;
boutonMicro.title = "Reconnaissance vocale non disponible sur ce navigateur";
} else {
reconnaissance = new ReconnaissanceVocale();
reconnaissance.lang = "fr-FR";
reconnaissance.continuous = true;
reconnaissance.interimResults = true;
reconnaissance.maxAlternatives = 1;
let dernierDemarrageHorodatage = 0;
let cyclesRapidesConsecutifs = 0;
const SEUIL_CYCLE_RAPIDE_MS = 800;
const MAX_CYCLES_RAPIDES = 5;
function passerEnEcoute(){
boutonMicro.classList.add("actif");
boutonMicro.textContent = "🎤 Écoute en cours…";
etatMicro.textContent = "micro ouvert";
reinitialiserMinuteurInactivite();
}
function revenirAuRepos(){
ecouteVoulue = false;
arreterMinuteurInactivite();
boutonMicro.classList.remove("actif");
boutonMicro.textContent = "🎤 Micro";
etatMicro.textContent = "";
}
boutonMicro.addEventListener("click", function(){
if(ecouteVoulue){
ecouteVoulue = false;
reconnaissance.stop();
return;
}
ecouteVoulue = true;
erreurBloquante = false;
try{
reconnaissance.start();
}catch(erreur){
ecouteVoulue = false;
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
}
});
reconnaissance.addEventListener("start", function(){
dernierDemarrageHorodatage = Date.now();
passerEnEcoute();
});
reconnaissance.addEventListener("end", function(){
if(ecouteVoulue && !erreurBloquante){
const ecoule = Date.now() - dernierDemarrageHorodatage;
cyclesRapidesConsecutifs = (ecoule < SEUIL_CYCLE_RAPIDE_MS) ? (cyclesRapidesConsecutifs + 1) : 0;
if(cyclesRapidesConsecutifs >= MAX_CYCLES_RAPIDES){
erreurBloquante = true;
revenirAuRepos();
afficherErreur("Le micro redémarre en boucle anormalement vite — arrêt automatique. Réessaie dans quelques secondes.");
return;
}
try{
reconnaissance.start();
}catch(erreur){
revenirAuRepos();
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
}
return;
}
revenirAuRepos();
});
reconnaissance.addEventListener("result", function(evenement){
const dernier = evenement.results[evenement.results.length - 1];
const texte = dernier[0].transcript.trim();
if(!texte) return;
if(dernier.isFinal){
figerLigne(texte);
reinitialiserMinuteurInactivite();
} else {
afficherEnCours(texte);
}
});
reconnaissance.addEventListener("error", function(evenement){
const messagesBloquants = {
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"audio-capture": "Aucun micro détecté."
};
if(messagesBloquants[evenement.error]){
erreurBloquante = true;
afficherErreur(messagesBloquants[evenement.error]);
}
});
}
// -------- Réinitialiser --------
boutonReinitialiser.addEventListener("click", function(){
if(ecouteVoulue && reconnaissance){ ecouteVoulue = false; reconnaissance.stop(); }
texteComplet = "";
ligneEnCours = null;
zoneLignes.innerHTML = "";
boutonInterpreter.disabled = true;
zoneResultat.classList.remove("visible");
messageCreation.textContent = "";
messageCreation.className = "";
});
// -------- Interpréter --------
function viderFormulaire(){
document.querySelectorAll("[data-champ]").forEach(function(el){ el.value = ""; el.classList.remove("interprete"); });
selectStatut.innerHTML = '<option value="">—</option>';
selectProfession.innerHTML = '<option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option>';
blocNouvelleProfession.style.display = "none";
champNouvelleProfession.value = "";
}
function remplirChampsTexte(champs){
document.querySelectorAll("[data-champ]").forEach(function(el){
const nomChamp = el.dataset.champ;
if(nomChamp === "CNT_Statut") return; // géré à part (select)
const valeur = champs[nomChamp];
if(valeur){
el.value = valeur;
el.classList.add("interprete");
}
});
}
function remplirStatuts(statuts, statutChoisi){
statuts.forEach(function(s){
const option = document.createElement("option");
option.value = s.STA_Code;
option.textContent = s.STA_Libellé;
selectStatut.appendChild(option);
});
if(statutChoisi){
selectStatut.value = statutChoisi;
selectStatut.classList.add("interprete");
}
}
function remplirProfessions(professions, idExistant, libelleNouveau){
professions.forEach(function(p){
const option = document.createElement("option");
option.value = String(p.PRF_Incr);
option.textContent = p.PRF_Libelle;
// Insertion avant l'option "+ Nouvelle profession…", toujours en dernier.
selectProfession.insertBefore(option, selectProfession.lastElementChild);
});
if(idExistant){
selectProfession.value = String(idExistant);
selectProfession.classList.add("interprete");
} else if(libelleNouveau){
selectProfession.value = "__nouvelle__";
selectProfession.classList.add("interprete");
blocNouvelleProfession.style.display = "block";
champNouvelleProfession.value = libelleNouveau;
}
}
selectProfession.addEventListener("change", function(){
blocNouvelleProfession.style.display = (selectProfession.value === "__nouvelle__") ? "block" : "none";
});
boutonInterpreter.addEventListener("click", function(){
if(!texteComplet) return;
boutonInterpreter.disabled = true;
boutonInterpreter.textContent = "Interprétation en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
fetch("contact_ia_interprete.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({texte: texteComplet})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(corps){
derniereExtraction = corps.extraction;
viderFormulaire();
remplirChampsTexte(corps.extraction.champs);
remplirStatuts(corps.statuts, corps.extraction.champs.CNT_Statut);
remplirProfessions(
corps.professions,
corps.extraction.profession_existante_id,
corps.extraction.profession_nouvelle_libelle
);
zoneResultat.classList.add("visible");
zoneResultat.scrollIntoView({behavior:"smooth", block:"start"});
}).catch(function(erreur){
afficherErreur("Échec de l'interprétation : " + erreur.message);
}).finally(function(){
boutonInterpreter.disabled = (texteComplet === "");
boutonInterpreter.textContent = "Interpréter →";
});
});
// -------- Valider (4 septembre 2026) --------
// Correction explicite de Jean-Marc : cet outil ne doit JAMAIS créer de
// contact réel (ni de profession réelle au passage) — contrairement à ce
// qu'une première version faisait en appelant contacts.php directement.
// L'objectif de cette étape est seulement d'accumuler, pour chaque essai,
// trois choses côte à côte (texte brut / ce que le LLM a compris / ce que
// Jean-Marc valide après relecture-correction) afin d'en tirer des
// conclusions sur la qualité de l'interprétation — et, plus tard, de
// servir de matière à la création d'un post-it par assistant pour chaque
// instruction vocale (vision "bureau à l'ancienne", pas encore câblée :
// ASP_ToDoList ne sait pas encore attribuer une tâche à un assistant, voir
// le "reste à faire" du journal de développement). Rien n'est donc écrit
// dans ASP_Contacts ni ASP_Professions depuis cette page — uniquement dans
// la nouvelle table ASP_PropositionsContact, via contact_ia_valide.php.
function collecterChamps(){
const champs = {};
document.querySelectorAll("[data-champ]").forEach(function(el){
champs[el.dataset.champ] = el.value.trim();
});
return champs;
}
function collecterProfessionValidee(){
if(selectProfession.value === "__nouvelle__"){
return {profession_existante_id: null, profession_nouvelle_libelle: champNouvelleProfession.value.trim() || null};
}
if(selectProfession.value){
return {profession_existante_id: parseInt(selectProfession.value, 10), profession_nouvelle_libelle: null};
}
return {profession_existante_id: null, profession_nouvelle_libelle: null};
}
boutonCreer.addEventListener("click", function(){
const champs = collecterChamps();
if(!champs.CNT_Nom){
messageCreation.className = "ko";
messageCreation.textContent = "Le nom est obligatoire.";
return;
}
boutonCreer.disabled = true;
boutonCreer.textContent = "Enregistrement en cours…";
messageCreation.textContent = "";
messageCreation.className = "";
const valide = Object.assign({champs: champs}, collecterProfessionValidee());
fetch("contact_ia_valide.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
texteBrut: texteComplet,
extraction: derniereExtraction,
valide: valide
})
}).then(function(reponse){
return reponse.json().then(function(corps){
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
return corps;
});
}).then(function(){
messageCreation.className = "ok";
messageCreation.textContent = "Essai enregistré (aucun contact réel créé) — merci !";
}).catch(function(erreur){
messageCreation.className = "ko";
messageCreation.textContent = "Échec de l'enregistrement : " + erreur.message;
}).finally(function(){
boutonCreer.disabled = false;
boutonCreer.textContent = "Valider";
});
});
})();
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* ASPIC — contact_ia_valide.php
*
* Outil de mise au point interne (4 septembre 2026, correction explicite de
* Jean-Marc suite à un premier essai de contact_ia_test.html/.js) — PAS un
* endpoint destiné aux futurs utilisateurs.
*
* Ne crée JAMAIS de contact réel (ni de profession réelle) : la première
* version de contact_ia_test.js appelait directement contacts.php/
* professions.php depuis cet outil de test, ce que Jean-Marc a explicitement
* écarté. Ce fichier se contente d'enregistrer, côte à côte, les trois
* étapes d'un essai d'interprétation :
* - le texte brut tel que dicté (identique à l'esprit de dictees_test.php) ;
* - ce que le LLM a compris (JSON renvoyé par contact_ia_interprete.php,
* avant toute correction) ;
* - ce que Jean-Marc a réellement validé après relecture (JSON des champs du
* formulaire tels qu'affichés/corrigés au moment du clic sur "Valider").
*
* Objectif explicite de Jean-Marc : comparer les deux JSON dans la durée
* pour juger la qualité de l'interprétation, et — plus tard — s'appuyer sur
* ce qui est validé pour générer un post-it par instruction vocale, attribué
* à un assistant (vision "bureau à l'ancienne"). Cette seconde partie n'est
* PAS implémentée ici : elle suppose qu'un assistant puisse être attributaire
* d'une tâche dans ASP_ToDoList, ce qui n'existe pas encore (voir "reste à
* faire" du journal de développement) — ASP_PropositionsContact est
* volontairement une table à part, pas une écriture anticipée dans
* ASP_ToDoList qui préjugerait d'un mécanisme pas encore conçu.
*
* POST uniquement, corps JSON :
* { "texteBrut": "...", "extraction": {...} | null, "valide": {...} }
* "extraction" peut être null si l'utilisateur clique "Valider" sans être
* passé par "Interpréter" (cas limite, pas bloqué ici).
*
* "valide" est stocké tel quel (JSON opaque pour ce fichier, jamais inspecté
* ni validé ici) : son contenu exact suit ce que contact_ia_panel.js
* construit côté client, et a changé le 4 septembre 2026 (encore le même
* soir) sur un point à connaître pour qui lira PCT_Valide plus tard (le
* futur traitement n8n notamment) — valide.actions est passé d'un tableau
* d'objets {action_existante_code, action_nouvelle_libelle} à un simple
* tableau de libellés texte (3 zones de saisie libre, demande de Jean-Marc :
* la liste dynamique existant/nouveau était trop complexe pour un essai
* vocal rapide, et l'objectif de cette phase est justement de repérer les
* actions non encore couvertes par ASP_Actions). Aucune conséquence sur le
* schéma de la table : PCT_Valide reste une colonne JSON sans forme imposée.
*
* ⚠️ Nécessite la création préalable de la table par Jean-Marc lui-même
* (Claude n'exécute jamais de modification de schéma — voir réflexions
* stratégiques) :
*
* CREATE TABLE ASP_PropositionsContact (
* PCT_Incr INT AUTO_INCREMENT PRIMARY KEY,
* PCT_TexteBrut TEXT NOT NULL,
* PCT_Extraction JSON NULL,
* PCT_Valide JSON NOT NULL,
* PCT_Utilisateur VARCHAR(10) NOT NULL,
* PCT_Date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
* );
*
* Session utilisateur : accès réservé aux utilisateurs identifiés, même
* principe que dictees_test.php/todolist.php.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$texteBrut = trim((string) ($data['texteBrut'] ?? ''));
$extraction = $data['extraction'] ?? null;
$valide = $data['valide'] ?? null;
if ($texteBrut === '') {
http_response_code(400);
echo json_encode(['error' => 'Rien à enregistrer (texte brut vide)']);
exit;
}
if (!$valide) {
http_response_code(400);
echo json_encode(['error' => 'Rien à enregistrer (champs validés absents)']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
$stmt = $pdo->prepare(
'INSERT INTO ASP_PropositionsContact (PCT_TexteBrut, PCT_Extraction, PCT_Valide, PCT_Utilisateur)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$texteBrut,
$extraction !== null ? json_encode($extraction, JSON_UNESCAPED_UNICODE) : null,
json_encode($valide, JSON_UNESCAPED_UNICODE),
$_SESSION['USR_Code'],
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage() . ' — la table ASP_PropositionsContact existe-t-elle ? (voir en-tête de ce fichier)']);
exit;
}
echo json_encode(['success' => true, 'id' => (int) $pdo->lastInsertId()]);
+112 -8
View File
@@ -56,11 +56,43 @@
* doit appeler `session_start()` et renseigner `$_SESSION['USR_Code']` au
* moment où l'identifiant/mot de passe sont validés ; sans cette mise à jour
* côté `aspic.php`, plus aucun appel à ce fichier ne pourra aboutir.
*
* ⚙️ Chantier des consignes (6 septembre 2026, voir Réflexions stratégiques) :
* POST et PUT n'ont plus aucun contrôle ni traitement écrit en dur sur les
* champs — un seul appel à appliquerConsignes() (moteur_consignes.php), qui
* lit ASP_Consignes/ASP_Fonctions à chaque appel et applique ce qu'il y
* trouve pour le contexte 'Contacts' (portée 'Création' ou 'Modification'
* selon le cas). Ajouter/modifier une consigne en base change donc le
* comportement réel sans toucher ce fichier — sauf si une fonction nécessaire
* n'existe pas encore dans consignes_fonctions.php, ou en cas de bug.
* Version volontairement simplifiée (voir en-tête de moteur_consignes.php) :
* pas de distinction Saisie/Validation (tout s'applique à l'enregistrement),
* pas de vérification en temps réel côté écran pour l'instant — décision de
* Jean-Marc du 6 septembre 2026, priorité à la robustesse.
*
* Journalisation des Traitements IA dans ASP_Realisations (7 septembre 2026,
* soir) : appliquerConsignes() peut remonter, via son paramètre par référence
* $journalisationsIA, une ou plusieurs entrées à journaliser (ex. code postal
* déduit par l'IA depuis la ville — voir consigne_completer_code_postal()).
* Écrites ICI, directement en base, plutôt que laissées au front-end comme
* CNT_CRE/CNT_MAJ (voir liste_contacts.html, validerFormulaireContact()) :
* REA_CodeUser/REA_NomAffiche reçoivent alors le nom d'agent IA (ex. "Matilda"
* — voir ASP_Agents.AGN_Prenom, et CNS_Agent sur la consigne qui a déclenché le
* Traitement), pas
* l'utilisateur humain de la session — hors de portée de realisations.php,
* qui impose volontairement $_SESSION['USR_Code'] (correctif de sécurité du
* 24 août 2026, voir ce fichier) et ne doit surtout pas rouvrir la
* possibilité qu'un client falsifie REA_CodeUser. Best-effort (try/catch
* séparé) : un souci de journalisation ne doit jamais faire échouer
* l'enregistrement du contact lui-même, même principe que la journalisation
* CNT_CRE/CNT_MAJ côté front.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/consignes_fonctions.php';
require __DIR__ . '/moteur_consignes.php';
// Accès réservé aux utilisateurs identifiés (session ouverte par aspic.php).
if (empty($_SESSION['USR_Code'])) {
@@ -107,6 +139,43 @@ function construireMotifLike(string $valeur): array
return [$motif, '\\'];
}
/**
* Journalise dans ASP_Realisations les Traitements IA remontés par
* appliquerConsignes() (voir moteur_consignes.php et le commentaire d'en-tête
* de ce fichier) — ex. code postal déduit de la ville. Best-effort : une
* exception ici est avalée (juste consignée au log serveur), jamais renvoyée
* au client — un souci de journalisation ne doit pas laisser croire que
* l'enregistrement du contact a échoué alors qu'il a réussi.
*/
function journaliserRealisationsIA(PDO $pdo, int $idContact, array $journalisationsIA): void
{
if (!$journalisationsIA) return;
try {
// REA_CodeUser ET REA_NomAffiche reçoivent la même valeur pour un
// agent IA (7 septembre 2026, nuit — "le prénom de l'agent en dur",
// Jean-Marc) : contrairement à un utilisateur humain, un agent n'a
// pas de code technique court séparé de son nom affiché — la valeur
// vient déjà en clair de CNS_Agent (voir moteur_consignes.php),
// aucune recherche supplémentaire à faire ici.
$stmt = $pdo->prepare(
'INSERT INTO ASP_Realisations (REA_IncrContact, REA_CodeUser, REA_NomAffiche, REA_Date, REA_Action, REA_Comment)
VALUES (?, ?, ?, NOW(), ?, ?)'
);
foreach ($journalisationsIA as $j) {
$nomAgent = (string)($j['agent'] ?? 'IA');
$stmt->execute([
$idContact,
$nomAgent,
$nomAgent,
(string)($j['action'] ?? 'IA'),
(string)($j['commentaire'] ?? ''),
]);
}
} catch (PDOException $e) {
error_log('ASPIC contacts: échec journalisation ASP_Realisations (Traitement IA) : ' . $e->getMessage());
}
}
try {
switch ($method) {
case 'GET':
@@ -123,6 +192,18 @@ try {
exit;
}
// Comptage seul (?compte=1), ajouté le 2 septembre 2026 pour la
// pastille du classeur "Contacts" sur l'écran de fond (voir
// journal section 28) : le nombre de contacts actifs, sans
// charger la liste complète juste pour ça. Volontairement
// indépendant des filtres f_* (le classeur affiche le total réel
// de la table, pas le résultat d'une recherche en cours).
if (isset($_GET['compte'])) {
$total = (int)$pdo->query('SELECT COUNT(*) FROM ASP_Contacts WHERE CNT_Actif = 1')->fetchColumn();
echo json_encode(['total' => $total]);
exit;
}
$tri = $_GET['tri'] ?? 'CNT_Nom';
if (!in_array($tri, COLONNES_TRI_AUTORISEES, true)) {
$tri = 'CNT_Nom';
@@ -171,10 +252,15 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$nom = trim($data['CNT_Nom'] ?? '');
if ($nom === '') {
$erreurConsigne = appliquerConsignes($pdo, 'Contacts', 'Création', $data, $agentConsigne, $journalisationsIA, $avertissementsIA);
if ($erreurConsigne !== null) {
http_response_code(400);
echo json_encode(['error' => 'Le nom est obligatoire']);
// 'agent' (CNS_Agent, 7 septembre 2026) : vaut JSON null si la
// consigne en échec n'en a pas — sans effet côté front, où
// afficherToast n'affiche la ligne agent que si la valeur est
// présente et non vide.
echo json_encode(['error' => $erreurConsigne, 'agent' => $agentConsigne]);
exit;
}
@@ -217,7 +303,14 @@ try {
$stmtCode = $pdo->prepare('UPDATE ASP_Contacts SET CNT_Code = ? WHERE CNT_Incr = ?');
$stmtCode->execute([$code, $nouvelIncr]);
echo json_encode(['id' => $nouvelIncr, 'code' => $code]);
journaliserRealisationsIA($pdo, $nouvelIncr, $journalisationsIA);
// 'avertissements' (7 septembre 2026, nuit) : messages à afficher
// immédiatement à l'écran, voir moteur_consignes.php ("_avertissement")
// — distinct de la journalisation ci-dessus, qui va dans
// l'historique mais ne s'affiche pas tout de suite. Tableau vide
// la plupart du temps, le front-end n'affiche alors rien.
echo json_encode(['id' => $nouvelIncr, 'code' => $code, 'avertissements' => $avertissementsIA]);
break;
case 'PUT':
@@ -227,10 +320,19 @@ try {
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$nom = trim($data['CNT_Nom'] ?? '');
if ($nom === '') {
// Dernier paramètre (8 septembre 2026) : identifiant du contact
// en cours de modification, transmis pour qu'une consigne
// d'unicité (ex. mail/portable déjà utilisé, voir
// consignes_fonctions.php) puisse exclure cette fiche elle-même
// de sa recherche — sans quoi ré-enregistrer un contact sans
// changer son mail se bloquerait lui-même. Absent en POST
// ci-dessus : en Création, aucun contact n'existe encore à
// exclure.
$erreurConsigne = appliquerConsignes($pdo, 'Contacts', 'Modification', $data, $agentConsigne, $journalisationsIA, $avertissementsIA, (int)$_GET['id']);
if ($erreurConsigne !== null) {
http_response_code(400);
echo json_encode(['error' => 'Le nom est obligatoire']);
echo json_encode(['error' => $erreurConsigne, 'agent' => $agentConsigne]);
exit;
}
@@ -266,7 +368,9 @@ try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true]);
journaliserRealisationsIA($pdo, (int)$_GET['id'], $journalisationsIA);
echo json_encode(['success' => true, 'avertissements' => $avertissementsIA]);
break;
case 'DELETE':
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* ASPIC — dictees_test.php
*
* Outil de mise au point interne (3 septembre 2026, demande de Jean-Marc) —
* PAS un endpoint destiné aux futurs utilisateurs. Reçoit, sans aucune
* interprétation, chaque énoncé capté par micro_test.js (branché
* temporairement sur le micro de l'écran de fond — voir le commentaire
* d'en-tête de ce fichier et celui d'ecran_fond.html) et l'enregistre tel
* quel dans ASP_DicteesTest — y compris les essais ratés, jamais filtrés ni
* supprimables depuis l'écran (décision explicite de Jean-Marc : un post-it
* raté fera aussi partie de la vraie vie des futurs utilisateurs, la banque
* de tests doit en tenir compte dès maintenant plutôt que de ne garder que
* les essais réussis).
*
* Objectif : accumuler un corpus réel de dictées sur le thème "Gestion des
* Contacts" — valeur par défaut de DCT_Theme, posée directement en base
* (DEFAULT 'Gestion des Contacts'), volontairement pas reprise ici pour ne
* pas dupliquer une valeur qui doit rester pilotée par la table — en vue de
* concevoir plus tard les règles d'interprétation du pilotage vocal (voir
* réflexions stratégiques).
*
* POST uniquement, corps JSON : { "texte": "..." } — un seul énoncé par
* appel (contrairement à agent_notes.php qui reçoit un lot de lignes d'une
* notation complète, ici chaque énoncé segmenté par le moteur de
* reconnaissance du navigateur est envoyé et enregistré immédiatement).
*
* Session utilisateur : accès réservé aux utilisateurs identifiés, même
* principe que les autres endpoints (agent_notes.php, todolist.php...).
*
* Ajout du 3 septembre 2026 (soir) — "mode adresse" de micro_test.js : le
* corps JSON peut porter en plus un champ optionnel "texteNormalise"
* (résultat du dictionnaire de conversion point/tiret/arobase appliqué
* côté client sur une adresse mail/URL dictée). Stocké dans une colonne
* séparée, DCT_TexteNormalise, JAMAIS à la place de DCT_Texte qui reste le
* texte brut tel que rendu par le moteur de reconnaissance — voir le
* commentaire d'en-tête de micro_test.js. NULL pour toutes les lignes hors
* "mode adresse", comme avant ce jour.
*
* ⚠️ Nécessite d'avoir ajouté la colonne en base avant déploiement :
* ALTER TABLE ASP_DicteesTest
* ADD COLUMN DCT_TexteNormalise VARCHAR(255) NULL DEFAULT NULL
* AFTER DCT_Texte;
* (modification de schéma à faire par Jean-Marc lui-même, comme toujours —
* voir réflexions stratégiques / journal sur ce point.)
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$texte = trim((string) ($data['texte'] ?? ''));
$texteNormalise = trim((string) ($data['texteNormalise'] ?? ''));
if ($texte === '') {
http_response_code(400);
echo json_encode(['error' => 'Rien à enregistrer']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
// DCT_Texte est un TEXT (pas de contrainte de longueur pratique, contrairement
// à CVX_Consigne dans ASP_ConsignesVoix qui est un varchar(200)) : la dictée
// brute est conservée intégralement, sans troncature — un énoncé sur les
// contacts (nom, profession, ville, téléphone...) peut être plus long qu'une
// simple consigne de tâche.
try {
$stmt = $pdo->prepare(
'INSERT INTO ASP_DicteesTest (DCT_Texte, DCT_Utilisateur, DCT_TexteNormalise) VALUES (?, ?, ?)'
);
$stmt->execute([$texte, $_SESSION['USR_Code'], $texteNormalise !== '' ? $texteNormalise : null]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
exit;
}
echo json_encode(['success' => true]);
+1287 -193
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* ASPIC — espace_utilisateur.php
*
* Résolution de l'espace disque privé de chaque utilisateur (11 septembre
* 2026, feuille de route "BackOffice" point 2). Reprend, en l'affinant, une
* proposition initiale de Jean-Marc (hacher le nom de l'instance/de la base
* en SHA256 tronqué) — écartée en cours de discussion : SHA256 est un hash
* RAPIDE, donc trivialement cassable par dictionnaire dès que l'univers des
* valeurs possibles est petit et connu (les noms d'instances/bases ASPIC ne
* sont ni nombreux ni secrets). La bonne pièce existe déjà dans ce projet :
* `LIEN_DOC_CLE_SECRETE` (voir prospects_core.php, fabriquerJetonDoc()) —
* même principe repris ici avec sa propre clé (`ESPACE_CLE_SECRETE`) :
*
* - Dossier "tenant" (une base de travail, ex. MS-DEV) : HMAC-SHA256(clé
* secrète, host+base), tronqué. Déterministe (pas besoin de le stocker),
* mais infaisable à deviner sans la clé — contrairement à un simple
* SHA256, la présence d'une clé secrète rend une attaque par dictionnaire
* inopérante même en connaissant tous les noms de bases possibles.
* - Dossier "utilisateur" (une ligne MIR_Users) : jeton ALÉATOIRE
* (random_bytes), jamais dérivé d'un nom ou d'un numéro — rien à deviner,
* rien à casser. Généré une seule fois, à la volée, au premier besoin
* réel (même principe de création différée que creerSiteSiBesoin() dans
* mission_agent.php) puis conservé dans MIR_Users.MIR_Espace.
*
* Rappel de architecture (voir Réflexions stratégiques, section 21) : cette
* opacité de nom n'est qu'une défense en profondeur. La vraie protection est
* que RACINE_ESPACES vit hors de toute racine servie par Apache (volume
* Docker dédié, ajouté le 11 septembre 2026) — aucune URL ne peut jamais
* l'atteindre, quel que soit le nom des dossiers.
*/
const RACINE_ESPACES = '/var/www/espaces_utilisateurs';
function cheminTenant(string $host, string $baseNom): string
{
$cle = getenv('ESPACE_CLE_SECRETE');
if (!$cle) {
error_log('ASPIC espace_utilisateur : ESPACE_CLE_SECRETE non configurée');
$cle = 'repli-non-securise'; // ne devrait jamais arriver en pratique — voir docker-compose.yml
}
return substr(hash_hmac('sha256', "$host|$baseNom", $cle), 0, 16);
}
/**
* Jeton opaque de l'utilisateur (ligne MIR_Users) — lit MIR_Espace, le
* génère et le persiste au besoin (jamais recalculé une fois posé, pour ne
* jamais faire "bouger" un dossier déjà créé sur disque).
*
* Clé = MIR_Email, jamais MIR_Code : MIR_Code n'est PAS unique dans
* MIR_Users (ex. "JMP" partagé par les lignes "JMP Dev"/"JMP Tests", deux
* comptes distincts du même humain sur deux bases différentes) — seul
* MIR_Email identifie une ligne sans ambiguïté (même colonne que la
* vérification de mot de passe, voir aspic.php).
*/
function jetonUtilisateur(PDO $pdoRouter, string $mirEmail): string
{
$stmt = $pdoRouter->prepare('SELECT MIR_Espace FROM MIR_Users WHERE MIR_Email = ? LIMIT 1');
$stmt->execute([$mirEmail]);
$ligne = $stmt->fetch();
if ($ligne && $ligne['MIR_Espace']) {
return $ligne['MIR_Espace'];
}
$jeton = bin2hex(random_bytes(8)); // 16 caractères hexadécimaux, aléatoires, jamais dérivés d'un nom
$pdoRouter->prepare('UPDATE MIR_Users SET MIR_Espace = ? WHERE MIR_Email = ?')
->execute([$jeton, $mirEmail]);
return $jeton;
}
/**
* Résout (et crée si besoin) le dossier physique complet d'un utilisateur.
* Retourne le chemin absolu sur le disque du conteneur.
*/
function resoudreEspaceUtilisateur(PDO $pdoRouter, string $mirEmail, string $host, string $baseNom): string
{
$dossierRelatif = cheminTenant($host, $baseNom) . '/' . jetonUtilisateur($pdoRouter, $mirEmail);
$chemin = RACINE_ESPACES . '/' . $dossierRelatif;
if (!is_dir($chemin)) {
@mkdir($chemin, 0777, true);
@chmod($chemin, 0777);
}
return $chemin;
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* ASPIC — espaces.php
*
* Endpoint du BackOffice (11 septembre 2026) — vérification/administration
* des espaces disque privés (voir espace_utilisateur.php). Construit pour
* répondre à un besoin explicite de Jean-Marc : pouvoir toujours retrouver,
* en un clic, le dossier réel d'un utilisateur — l'opacité des noms de
* dossiers protège contre un visiteur extérieur, jamais contre lui.
*
* GET : pour chaque ligne de MIR_Users, le chemin résolu (calculé, jamais
* deviné), s'il existe déjà sur disque, et sa taille.
* POST { "action": "provisionner", "MIR_Code": "JMP" } : force la création
* immédiate du dossier (au lieu d'attendre un premier besoin réel).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/espace_utilisateur.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdoRouter = getRouterConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion à MIRADOR impossible']);
exit;
}
function tailleDossier(string $chemin): int
{
if (!is_dir($chemin)) return 0;
$total = 0;
$iterateur = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($chemin, FilesystemIterator::SKIP_DOTS));
foreach ($iterateur as $fichier) {
if ($fichier->isFile()) $total += $fichier->getSize();
}
return $total;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$lignes = $pdoRouter->query('SELECT MIR_Email, MIR_Code, MIR_ServeurHost, MIR_BaseNom, MIR_Espace FROM MIR_Users ORDER BY MIR_Email')->fetchAll();
$resultat = [];
foreach ($lignes as $l) {
$dossierRelatif = cheminTenant($l['MIR_ServeurHost'], $l['MIR_BaseNom']) . '/' . ($l['MIR_Espace'] ?: '(pas encore créé)');
$cheminAbsolu = RACINE_ESPACES . '/' . $dossierRelatif;
$existe = $l['MIR_Espace'] && is_dir($cheminAbsolu);
$resultat[] = [
'MIR_Email' => $l['MIR_Email'],
'MIR_Code' => $l['MIR_Code'],
'MIR_BaseNom' => $l['MIR_BaseNom'],
'dossier_relatif' => $dossierRelatif,
'existe' => $existe,
'taille_octets' => $existe ? tailleDossier($cheminAbsolu) : 0,
];
}
echo json_encode(['espaces' => $resultat]);
exit;
}
if ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$action = (string) ($data['action'] ?? '');
$mirEmail = trim((string) ($data['MIR_Email'] ?? ''));
if ($action !== 'provisionner' || $mirEmail === '') {
http_response_code(400);
echo json_encode(['error' => 'Requête invalide']);
exit;
}
// Clé MIR_Email, jamais MIR_Code — voir commentaire de jetonUtilisateur()
// dans espace_utilisateur.php (MIR_Code n'est pas unique dans MIR_Users).
$stmt = $pdoRouter->prepare('SELECT MIR_ServeurHost, MIR_BaseNom FROM MIR_Users WHERE MIR_Email = ? LIMIT 1');
$stmt->execute([$mirEmail]);
$ligne = $stmt->fetch();
if (!$ligne) {
http_response_code(404);
echo json_encode(['error' => 'Utilisateur introuvable dans MIRADOR']);
exit;
}
$chemin = resoudreEspaceUtilisateur($pdoRouter, $mirEmail, $ligne['MIR_ServeurHost'], $ligne['MIR_BaseNom']);
echo json_encode(['success' => true, 'chemin' => $chemin]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — Identification</title>
<!-- Icône d'onglet (3 septembre 2026, voir journal) : logo Mévélia Solutions,
fourni par Jean-Marc, reprise à l'identique sur tous les écrans. -->
<link rel="icon" href="mevelia-icone.png">
<style>
@font-face {
font-family: 'MetroDF';
+19 -13
View File
@@ -4,7 +4,7 @@
Ce document explique comment utiliser ASPIC au quotidien, écran par écran. Il est écrit pour l'utilisateur de l'application, pas pour un développeur — pour les détails techniques (choix d'architecture, code, structure de la base de données), voir le "Journal de développement ASPIC", document séparé dans ce même projet.
Ce premier jet couvre les deux écrans construits à ce jour (identification et liste des contacts). Il sera complété au fur et à mesure que de nouveaux écrans seront ajoutés à l'application.
Ce premier jet couvre les écrans construits à ce jour (identification, écran d'accueil, liste des contacts). Il sera complété au fur et à mesure que de nouveaux écrans seront ajoutés ou documentés — en particulier le panneau de détail du contact, la ToDoList et le pilotage vocal, déjà en service mais pas encore décrits ici.
## 1. Se connecter à ASPIC
@@ -25,15 +25,25 @@ Pour fermer ce message d'erreur sans quitter l'application et corriger votre sai
**Quitter l'application** : la touche **Échap**, utilisée à tout autre moment sur cet écran (aucun message d'erreur affiché), ferme directement l'application.
Une fois l'identification réussie, vous êtes automatiquement redirigé vers l'écran "Liste des contacts".
Une fois l'identification réussie, vous êtes automatiquement redirigé vers l'**écran d'accueil** (le bureau — voir ci-dessous).
## 2. La liste des contacts
## 2. L'écran d'accueil
C'est l'écran d'accueil de l'application une fois connecté : un tableau présentant tous vos contacts actifs, avec sept colonnes — Nom, Prénom, Département, Ville, Téléphone, Portable, Mail.
C'est le premier écran affiché après connexion : une photo de bureau, sur lequel sont posés les objets et raccourcis fonctionnels de l'application.
- Le **classeur "Contacts"** (à gauche) s'ouvre d'un clic et affiche la liste des contacts (voir section 3). Une petite pastille sur le classeur indique en permanence le nombre de contacts actifs.
- Le **micro**, au centre du bureau, permet de basculer son affichage entre inactif et en écoute — le pilotage vocal réel n'est pas encore documenté ici.
- En bas à droite de l'écran : votre société, votre prénom, et le numéro de version de l'application — les mêmes informations, à la même place, se retrouvent sur l'écran "Liste des contacts" (voir section 3).
La touche **Échap** n'a pas d'effet particulier sur cet écran pour l'instant.
## 3. La liste des contacts
Cet écran s'ouvre depuis l'écran d'accueil (clic sur le classeur "Contacts") : un tableau présentant tous vos contacts actifs, avec sept colonnes — Nom, Prénom, Département, Ville, Téléphone, Portable, Mail.
### Se repérer dans la liste
La barre en bas de l'écran indique en permanence : le nombre de contacts actuellement affichés sur le nombre total de contacts actifs, le tri en cours, et le filtre actif s'il y en a un.
Le bandeau du haut porte le titre de l'écran. La barre en bas indique en permanence : le nombre de contacts actuellement affichés sur le nombre total de contacts actifs, le tri en cours, le filtre actif s'il y en a un, ainsi que votre société, votre prénom et le numéro de version (même bloc qu'à l'accueil, voir section 2).
### Trier la liste
@@ -44,7 +54,7 @@ Un clic sur l'en-tête **Nom** ou **Ville** trie la liste sur cette colonne ; un
- Un clic sur une ligne la sélectionne (encadré vert).
- Les flèches **haut/bas** du clavier déplacent la sélection ligne par ligne.
- **Page précédente / Page suivante** font défiler la liste par blocs.
- La touche **Entrée**, une fois un contact sélectionné, ouvre son détail *(écran à venir — pour l'instant un message temporaire s'affiche à la place)*.
- La touche **Entrée**, une fois un contact sélectionné, ouvre son détail dans un panneau latéral.
### Actions sur un contact : le menu contextuel
@@ -62,15 +72,11 @@ La case à cocher **Supprimer les filtres**, en haut du panneau, vide et désact
Une fois les filtres saisis, le bouton **Appliquer** met à jour la liste ; **Réinitialiser** efface la saisie du formulaire sans quitter le panneau. L'icône entonnoir se met en surbrillance dès qu'un filtre est actif, pour le rappeler visuellement même une fois le panneau refermé.
### Le menu principal
### Fermer un panneau, ou revenir à l'accueil
L'icône en forme de menu ("hamburger"), en haut à gauche, ouvre un menu avec les grandes sections de l'application. À ce stade, seule *Gestion des contacts* (cet écran) est active ; *Gestion des actions* et *Documentation* apparaissent grisées, en attente de leur développement.
### Fermer un panneau ouvert
Quel que soit le panneau actuellement ouvert (menu contextuel, menu principal, ou panneau de filtre), la touche **Échap** le referme. Si aucun panneau n'est ouvert, Échap n'a pas d'effet sur cet écran.
Quel que soit le panneau actuellement ouvert (menu contextuel, panneau de détail, ou panneau de filtre), la touche **Échap** le referme. Si aucun panneau n'est ouvert, Échap vous ramène à l'écran d'accueil (section 2).
## À venir
- **Écran de détail du contact** : consultation et modification des informations d'un contact, et de l'historique de ses réalisations (actions menées auprès de lui — appels, échanges, prospections… — chacune pouvant porter son propre commentaire).
- **Panneau de détail du contact**, **ToDoList** et **pilotage vocal** : déjà en service dans l'application, pas encore décrits dans ce document — à rédiger.
- Les données affichées correspondent à vos contacts actifs uniquement ; un contact supprimé n'est pas détruit mais simplement retiré de cette liste (récupérable en cas de besoin, fonctionnalité à venir).
File diff suppressed because one or more lines are too long
+477
View File
@@ -126,3 +126,480 @@ En marge de sa recherche d'un serveur Hostinger plus puissant, Jean-Marc a déco
**Périmètre et statut.** Vision explicitement limitée à ASPIC lui-même à ce stade, pas un projet d'outillage plus large. Jean-Marc reconnaît que cette réflexion peut évoluer avec l'expérience acquise sur le terrain — voir justement le principe ci-dessous, tiré d'un premier retour d'expérience concret le jour même. Aucun développement engagé — pure réflexion à ce stade, capturée ici pour mémoire.
**Principe de conception qui en découle, tiré de l'expérience réelle du pilotage vocal (2 septembre 2026) :** mieux vaut choisir des noms d'agents qui passent bien nativement à la reconnaissance vocale que d'empiler des tolérances pour rattraper un nom mal choisi — chaque tolérance ajoutée étant à la fois un risque de faux déclenchement (si elle recoupe un mot français courant) et un pari sur un seul accent/une seule prononciation testée, fragile face à la diversité réelle des utilisateurs. Deux agents (Lilou, Karl) ont été retirés sur cette base après des essais répétés peu concluants, plutôt que d'être maintenus à coups de correctifs successifs. Principe à garder en tête si le "bureau à l'ancienne" se concrétise un jour avec davantage d'assistants nommés.
## 12. Interpréter le pilotage vocal sans s'effondrer à l'échelle — LLM à sortie structurée pour l'action, RAG réservé à la recherche (3 septembre 2026)
**Origine — un enjeu concurrentiel identifié via un tiers.** Jean-Marc a montré les évolutions du pilotage vocal à sa compagne, qui a immédiatement fait le rapprochement avec sa propre expérience professionnelle : elle travaille dans une grosse PME équipée de SAP, confrontée au quotidien à une application qui rame pendant de longues minutes, des arrêts brutaux, et des procédures extrêmement lourdes pour la moindre intervention (créer un utilisateur, corriger un bug de saisie) — délai moyen de 2 à 3 jours via un prestataire externe. Jean-Marc en tire une conviction stratégique : le pilotage vocal, davantage qu'un simple confort, pourrait devenir un vrai argument différenciant face à ce type d'outil lourd, auprès de futurs clients de son agence. **Conséquence directe sur l'exigence de qualité :** cette perspective augmente la pression de fiabilité plutôt que de la relâcher — Jean-Marc redoute explicitement le scénario inverse ("sympa en démo, mais ça ne marche pas en vrai"), d'autant qu'il ne croit pas à la viabilité d'un empilement de règles/rustines au cas par cas une fois le pilotage vocal étendu bien au-delà de la ToDoList (contacts, catalogue, devis et affaires en cours, facturation).
**Deux problèmes distincts, à ne pas confondre sous un seul chantier.** Discussion posée avec Claude pour trancher cette inquiétude :
- **La reconnaissance du mot déclencheur** (l'épisode Lilou/"les loups", voir journal de développement section 27) est un problème de transcription audio, en amont de tout traitement — aucune base vectorielle, aucun RAG, aucun LLM n'y changerait quoi que ce soit, c'est une limite de la reconnaissance vocale elle-même (la même que rencontrent Alexa, Siri ou Google Home). Déjà traité par le choix de noms qui passent bien à l'oral et par `AGN_Variantes` piloté en base — et point rassurant pour la suite : cette surface d'erreur ne grandit pas avec le nombre de modules d'ASPIC, elle reste bornée au nombre d'agents nommés.
- **L'interprétation d'une dictée déjà bien captée** — transformer "il faut ajouter un contact, il s'appelle Pierre Chartier, il est plombier à Quimper..." en une action structurée (quel module, quelle opération, quels paramètres) — est un problème différent, qui se posera réellement une fois la ToDoList suivie par la gestion des contacts, du catalogue, des devis, de la facturation.
**Pourquoi le RAG/pgvector n'est pas l'outil pour ce deuxième problème.** Un RAG/base vectorielle sert à retrouver de l'information pertinente par similarité sémantique dans un corpus large et non structuré — une recherche, pas un déclenchement d'action. C'est l'outil du **répondeur téléphonique intelligent** (section 10 ci-dessus), où le besoin est justement de chercher une réponse dans une base de connaissances ouverte, avec une contrainte de latence quasi temps réel. Le pilotage vocal d'ASPIC, même étendu à plusieurs modules, reste un ensemble d'actions **enumérable et borné** (créer un contact, ajouter une ligne de devis, appliquer une remise...) — la bonne réponse est un LLM à sortie structurée (function calling) : on lui fournit un catalogue d'actions disponibles (nom, paramètres attendus, endpoint concerné), il renvoie l'action reconnue et ses paramètres extraits du texte, sans deviner s'il n'est pas sûr. Chaque nouveau module ajoute des entrées à ce catalogue, pas du code d'interprétation nouveau — c'est ce qui évite l'empilement de règles redouté par Jean-Marc, bien mieux qu'une base vectorielle qui ne répond pas à ce besoin précis. Le RAG garde sa place légitime, mais ailleurs : recherche floue dans un futur catalogue produits ("quelle référence pour un connecteur étanche IP67 20A"), ou dans l'historique des devis/affaires — pas pour interpréter une commande. L'infrastructure (PostgreSQL/pgvector, n8n, LangFlow) tourne déjà sur le VPS de Jean-Marc, prête sans réinstallation le jour où un vrai besoin de ce type apparaîtra (voir section 10).
**Modèle affiné pour le retour visuel à l'utilisateur : deux statuts distincts, pas un seul niveau de confiance.** Proposition initiale de Claude (un seuil de confiance unique) affinée par Jean-Marc, qui avait déjà anticipé le besoin d'une correction ou d'une validation propre à l'action demandée :
- **"À corriger"** — l'IA n'a pas réussi à comprendre proprement la demande (information manquante, incohérente, ou dictée incompréhensible). Un problème d'interprétation, détecté par le LLM lui-même au moment de faire correspondre la dictée à une action du catalogue.
- **"À valider"** — l'IA a bien compris et préparé l'action, mais cette action précise exige toujours un accord humain avant de partir pour de vrai (ex. l'envoi d'un devis à un client), **indépendamment du niveau de confiance de l'interprétation** — une propriété portée par l'action elle-même dans le catalogue, pas un seuil réglé globalement.
Jean-Marc a proposé un signe graphique sur le post-it de l'assistant pour identifier ce qui l'attend (un point d'interrogation, par exemple) — retenu pour "à corriger" (il doit apporter une information ou trancher une ambiguïté). Un second signe, distinct, est envisagé pour "à valider" (un geste plus simple, oui/non, sur quelque chose déjà prêt) — non choisi à ce stade, pour que les deux situations restent reconnaissables d'un coup d'œil sans ouvrir le post-it.
**Rôle de n8n confirmé, dans les mots de Jean-Marc : un "agent de veille" réagissant à des déclencheurs programmés** — pas un outil interrogé à la demande. Concrètement, soit une surveillance périodique des nouvelles clôtures de notation, soit un appel direct dès la clôture d'une dictée pour plus de réactivité — un réglage à choisir plus tard, pas une contrainte d'architecture.
**Décision pour la suite immédiate : ne pas concevoir les règles d'interprétation à l'avance, constituer d'abord un corpus réel.** Cohérent avec la leçon déjà tirée de Lilou/"les loups" — ce qui paraît naturel en le pensant seul se révèle différent une fois dicté réellement et de façon détendue. Jean-Marc en a fourni la preuve lui-même, avant même d'avoir l'outil en main : il a par exemple relevé qu'il commence souvent une instruction par "il faut"/"il faudrait" plutôt que par le prénom d'un agent, et utilise aussi bien "puis" que "ensuite" pour enchaîner deux idées — deux détails qu'aucune conception à la table n'aurait fait ressortir aussi vite. Voir journal de développement, section 27, pour l'outil concret construit ce soir-là (`micro_test.js`/`dictees_test.php`, table `ASP_DicteesTest`) : aucune règle d'interprétation n'y est codée, volontairement — l'objectif de cette phase est uniquement d'accumuler la matière première (dictées réelles, y compris les essais ratés) qui permettra, plus tard, de concevoir le catalogue d'actions et les règles d'extraction avec des exemples réels plutôt qu'hypothétiques.
**Statut : orientation actée, aucun développement de la couche d'interprétation elle-même engagé.** Le développement immédiat porte uniquement sur la constitution du corpus (voir journal, section 27) — le catalogue d'actions, l'appel LLM à sortie structurée et les deux signes graphiques du post-it restent à concevoir, une fois ce corpus jugé suffisant.
**Addendum du 3 septembre 2026 (nuit) — la reconnaissance brute d'un mot rare/inventé est un problème à part, que ni le LLM d'interprétation ni le RAG ne résolvent.** Les premiers essais réels de Jean-Marc sur le thème "Gestion des Contacts" (voir journal, section 27) ont fait remonter, en marge de la constitution du corpus, un problème concret qui recoupe la distinction posée plus haut mais s'y ajoute : la dictée d'une adresse mail échoue très souvent, pour trois raisons distinctes — un vocabulaire de ponctuation dicté ("point", "tiret", "arobase") converti de façon incohérente par le moteur gratuit du navigateur, une segmentation de la dictée en plusieurs lignes non fiable, et surtout des lettres purement et simplement avalées sur des mots sans aucun support linguistique réel (ex. "mvl-tls.fr" transcrit "mvtls.fr") — cette dernière étant une perte acoustique en amont de tout traitement, jamais rattrapable par un post-traitement quel qu'il soit, contrairement aux deux premières.
Point explicitement acté avec Jean-Marc : **aucune solution, gratuite ou payante, ne garantit un fonctionnement parfait en toute situation** sur ce genre de mot rare/inventé — à ne jamais présenter comme un problème que "changer de moteur" réglerait entièrement. Le levier le plus directement pertinent pour ce cas précis n'est pas le modèle d'interprétation (LLM à sortie structurée, voir plus haut) mais le **vocabulaire personnalisé côté reconnaissance elle-même** ("boosting") — un mécanisme distinct de celui décrit plus haut dans cette section, en amont de l'interprétation, pas à sa place :
- **Google Cloud Speech-to-Text** (*speech adaptation* / *phrase hints*) et **Deepgram** (*keyterm prompting*, récemment étendu à plusieurs langues) permettent de fournir au moteur une liste de mots/expressions attendus (noms de domaine ASPIC, société, agents...) pour augmenter leur probabilité de reconnaissance au moment de la décision — conceptuellement la même idée que `AGN_Variantes`, déjà validée pour les prénoms d'agents, appliquée cette fois au vocabulaire métier.
- **Non disponible côté gratuit :** l'API du navigateur (`webkitSpeechRecognition`) expose bien un paramètre `grammars` prévu à l'origine pour ce genre d'usage, mais Chrome ne l'exploite pas réellement en pratique (accepté sans effet) — ce levier suppose de passer par une vraie API payante.
- **Prix vérifiés le 3 septembre 2026** (susceptibles d'évoluer) : Google Cloud Speech-to-Text v2, reconnaissance standard, environ 0,016 $/minute (dégressif au volume) — seul fournisseur à documenter une option dédiée à la conversion ponctuation dictée→symbole (`enableSpokenPunctuation`), sans confirmation que le français soit couvert par cette option précise. Deepgram Nova-3 multilingue, environ 0,005 $/minute, formatage automatique ("Smart Formatting") inclus sans supplément, 200 $ de crédit d'essai gratuit — couverture française du *keyterm prompting* spécifiquement non confirmée par la documentation à ce jour. OpenAI (gpt-transcribe), environ 0,0045 $/minute en asynchrone (plus cher en flux continu), sans fonction de ponctuation dictée documentée.
- **Le coût en dollars est négligeable à l'usage envisagé** (quelques dollars par mois même à un usage quotidien soutenu) — ce n'est pas ce qui doit trancher. Ce qui coûte réellement, c'est le changement d'architecture : ces API ne s'utilisent pas depuis le navigateur comme aujourd'hui (gratuit, zéro serveur) — il faudrait capturer l'audio et le faire transiter par un point d'entrée serveur détenant une clé tenue secrète, fin de la solution 100% navigateur actuelle.
**Décision : épuiser d'abord la piste gratuite (dictionnaire de conversion + segmentation explicite au clic, "mode adresse" de `micro_test.js`, voir journal section 27) avant d'investir dans une API.** Pas de décision définitive sur le recours à une API — dépendra du taux d'échec réellement mesuré sur la campagne de tests que Jean-Marc prévoit de mener le 4 septembre. Si le taux reste insuffisant, Deepgram est le candidat à tester en premier (le moins cher, formatage inclus), Google Cloud restant l'option la plus directement conçue pour le problème de ponctuation dictée mais à vérifier en vrai plutôt que sur la seule doc, notamment pour la couverture du français.
## 13. L'interprétation structurée passe l'épreuve du réel, et fait émerger un nouveau besoin : séparer les actions "humaines" des actions "vocales" (4 septembre 2026)
**L'hypothèse de la section 12 est validée en conditions réelles, pas seulement sur le papier.** Premier outil réel d'interprétation d'une dictée par LLM à sortie structurée (voir journal de développement, section 29, pour le détail technique — `contact_ia_interprete.php`/`contact_ia_valide.php`/`contact_ia_panel.js`, jamais d'écriture dans les vraies tables de contacts, tout passe par une table à part `ASP_PropositionsContact`). Bilan de Jean-Marc sur un premier corpus réel de 11 essais, sans ambiguïté : *"c'est vraiment super ! Les tests sont vraiment encourageants."* Deux points restent ouverts (une adresse mail mal nettoyée d'un mot d'appel et d'un accent ; une possible troncature de fin de dictée par le nouveau minuteur de silence) mais ne remettent pas en cause l'approche elle-même — ce sont des réglages fins, pas un problème de méthode. Point vérifié explicitement avec Jean-Marc et à retenir comme garde-fou de confiance dans l'outil : la détection et le classement en premier de l'action "ajouter le contact" sont une vraie décision du LLM portée par le prompt, pas un cas particulier codé en dur côté Claude.
**Décision architecturale actée en cours de session, à retenir comme principe général pour toute future couche d'interprétation vocale :** cette couche doit rester un enregistreur honnête de ce qui a été réellement dit, jamais un lieu où s'invitent des règles métier. Concrètement : l'attribution d'un assistant par défaut quand aucun n'est nommé dans la dictée n'est **pas** de son ressort — cette décision reviendra à un futur traitement n8n, au moment de transformer une proposition validée en vrai post-it. Mélanger les deux aurait fait perdre la trace de ce que Jean-Marc a réellement dicté derrière une interprétation déjà "arrangée".
**Nouveau besoin architectural identifié par Jean-Marc à l'usage, non conçu ni codé — à reprendre dans une prochaine session.** En listant les actions qu'un contact peut porter (envoyer une doc, proposer un tarif...), Jean-Marc a réalisé que la création du contact lui-même mérite d'être une action à part entière au même titre que les autres — mais une action à double accès : le classement la place toujours en premier, elle est bien connue du LLM vocal (qui doit la détecter et la renvoyer classée), **et** l'utilisateur humain doit pouvoir la déclencher directement depuis un menu ou un raccourci clavier, sans passer par la voix. Jean-Marc souhaite généraliser ce principe à l'ensemble des actions d'ASPIC : une séparation formelle entre les actions utilisables par l'utilisateur humain (menu/écran classique) et celles utilisables par le LLM de saisie vocale, tout en gardant les deux voies d'accès parfaitement alignées sur un même vocabulaire d'actions et un même ordre. C'est précisément le motif qui structure sa satisfaction du jour : *"ce qui me plaît c'est que je vois bien que j'arrive à suivre ma logique : des fonctionnalités accessibles via des menus et écrans de façon tout à fait classique et ces mêmes fonctionnalités accessibles par des ordres vocaux fiables donnés à des assistants !"* — une confirmation, par la pratique, du principe de double accès déjà esquissé dans la vision "bureau à l'ancienne" (section 11) : mêmes fonctionnalités, deux portes d'entrée. Rien n'est conçu ni codé sur ce point à ce stade ; voir journal de développement, section 29, pour le détail du besoin tel que formulé.
**Situation acceptée comme normale, pas un défaut à corriger : une profession peut être proposée "nouvelle" plusieurs fois avant traitement.** Deux dictées distinctes du corpus ont fait remonter deux fois la même profession ("Informaticien") comme nouvelle. Jean-Marc a tranché sans hésitation : c'est acceptable tant qu'un assistant n'a pas traité le post-it — il suffira, au moment du traitement, de vérifier si la profession n'a pas déjà été créée entre l'enregistrement de la note vocale et son traitement. Pas de mécanisme de déduplication à construire à ce stade.
## 14. Le pilotage vocal comme deuxième mode de fonctionnement — consignes en langage naturel plutôt que règles programmées, et naissance du "journalier" (5-6 septembre 2026)
**Origine — un blocage ancien qui refait surface autrement.** Jean-Marc a repris une réflexion mise de côté depuis la gestion des contacts : il s'était alors arrêté sur l'idée d'ajouter un menu à ribambelle d'options pour les actions possibles sur un contact, jugé indigeste (empilement d'écrans), sans trouver d'alternative à l'époque. En pratiquant ses essais de dictée (initialement destinés uniquement à valider que la retranscription était fidèle, voir journal de développement section 27/29), il s'est surpris, une fois détendu sur la technique, à vouloir donner une instruction longue et naturelle comme à une vraie assistante — exemple donné : *"ajouter aux contacts Pierre Beurel que j'ai rencontré ce midi au restaurant, qui est plombier à Lorient [...] Envoyez-lui la doc du répondeur [...] et il va falloir le suivre de près"*. Non fait, mais le seul fait d'en avoir eu l'envie a déclenché cette session de réflexion stratégique.
**Hypothèse initiale, posée puis dépassée en cours d'échange.** Première piste explorée : réserver le clavier aux opérations structurantes (définir les données de base) et la voix à la "mise en musique" (passer à l'action), avec un socle d'actions atomiques (créer un contact, envoyer un doc précis) accessible par les deux voies — dans la continuité du principe de double accès déjà acté le 4 septembre (section 13). Cette hypothèse a été explicitement corrigée par Jean-Marc sur un point : même une action atomique n'a pas sa place sur l'écran du classeur de contacts.
**Le classeur de contacts n'est, et ne sera jamais, un outil d'action — seulement un répertoire et un historique.** Jean-Marc s'appuie sur la métaphore du bureau à l'ancienne (section 11) pour trancher : ouvrir le classeur, le feuilleter, ajouter une fiche, en déchirer une — c'est la vie du classeur lui-même, pas une action métier. Voir une fiche et juger qu'il faut agir dessus (envoyer une doc, programmer un suivi) ne se traite jamais en restant dans le classeur — dans la vraie vie, ça se note sur un bout de papier, ça se vérifie éventuellement sur des profils similaires, ça se discute avec un collaborateur. Conséquence actée : aucune action métier, même simple, ne sera déclenchable depuis la fiche contact elle-même ; agir passe entièrement par le canal d'instruction en langage naturel (voix ou texte), la consultation reste entièrement séparée.
**Le vrai changement de paradigme : des consignes en langage naturel, pas des règles programmées par écrans.** Jean-Marc a explicitement rejeté l'idée de construire les règles de fonctionnement en remplissant des écrans à embranchements ("comme on programmait des chatbots autrefois... qui finissait toujours par mener à un mur"). À la place : une "ribambelle de consignes" en phrases simples, à la manière dont un dirigeant forme son personnel — exemples donnés tels quels par Jean-Marc : *"les documentations sont envoyées par mail"*, *"nous avons 3 documentations : générale, répondeur, CRM"*, *"toutes nos données sont hébergées en Europe, on est RGPD"*, *"en règle générale on relance le contact après l'envoi d'une documentation au bout d'une semaine"*. Aucune catégorisation a priori de ces phrases (fait, règle de comportement, contrainte réglementaire) n'est nécessaire ni souhaitée — cohérent avec le rejet des écrans à embranchements, et laissé à la capacité de compréhension du LLM tant que la liste reste d'une taille raisonnable à tenir en contexte (le jour où elle grossit trop, la question d'une recherche plutôt qu'une inclusion intégrale se reposera, comme déjà anticipé pour le RAG en section 12 — non traité maintenant).
**Cadre revendiqué explicitement par Jean-Marc : la démarche qualité PDCA (plan-do-check-act), en opposition assumée à une tendance qu'il s'attribue à lui-même/à une culture française** ("qui pense faire tout de suite parfait et [...] s'attaque à des séries d'Everest" plutôt que de monter les marches une à une). Il relie cette prise de conscience à la discipline déjà appliquée depuis le début du projet (constituer un corpus de dictées réelles avant de concevoir des règles d'interprétation, voir section 12) — pas une nouveauté de méthode, mais une explicitation du principe déjà à l'œuvre.
**Processus en quatre phases, proposé par Jean-Marc pour l'interprétation d'une instruction :**
1. **Traduction globale** — la dictée (ou une saisie texte dans une fenêtre dédiée, les deux canaux étant désormais vus comme équivalents, la voix n'étant qu'une des deux portes d'entrée du même pipeline) est analysée pour produire une reformulation fidèle de la demande. Cette phase reste l'enregistreur honnête déjà posé comme principe le 4 septembre (section 13) — aucune règle métier n'y entre.
2. **Vérification de la compréhension** de la demande par rapport aux consignes établies, puis transmission à l'assistant concerné (matérialisée par la création d'un post-it — détail d'interface, non structurant).
3. **Traitement par l'IA et proposition de retour** — un retour n'est pas systématique : une suppression de fiche sans enjeu peut ne rien nécessiter, sauf si, par exemple, un devis est en cours. Ce point généralise le principe déjà posé le 3 septembre (section 12) selon lequel le statut "à valider" est une propriété portée par l'action et non un seuil de confiance global : cette propriété devient elle-même une décision calculée par les consignes selon le contexte réel, plutôt qu'une caractéristique fixée une fois pour toutes par type d'action.
4. **Validation ou abandon** des actions proposées par Jean-Marc (ou plus tard un collaborateur) — un abandon pouvant, à terme, entraîner une évolution des consignes elles-mêmes.
**Acceptation explicite que l'assistant développe "sa propre réflexion" et propose au-delà du cadre strictement anticipé.** Jean-Marc assume pleinement ce point, en s'appuyant sur une analogie de management : un dirigeant autoritaire qui a toujours raison bride l'initiative de son personnel, quand un dirigeant plus ouvert (sans tout accepter) permet à la structure de capitaliser et de progresser. Il y voit l'usage réellement différenciant de l'IA pour son futur agence, et un argument commercial potentiel (*"Adoptez l'outil qui va apprendre avec vous !"*), en contraste direct avec des outils lourds et figés (l'expérience SAP de sa compagne, section 12). **Nuance apportée par Claude et retenue comme point de vigilance pour la crédibilité de cet argument commercial :** contrairement à un employé humain, une IA n'apprend spontanément de rien d'un appel à l'autre — la capitalisation ne sera réelle que parce que le mécanisme pour la capturer (consignes écrites, journalier conservé, boucle validation/abandon) est construit explicitement à cet effet. L'argument de vente doit donc s'appuyer sur cette discipline explicite plutôt que sur une "magie" de l'IA que rien ne garantirait techniquement.
**Décision actée : naissance du "journalier".** Un des classeurs du bureau à l'ancienne (section 11) portera ce nom et consignera les actions réalisées par les assistants (et possiblement l'utilisateur humain — périmètre exact non encore tranché) par demi-journée, avec leurs résultats : un outil d'aide à la décision pour le dirigeant. Terme choisi par Jean-Marc en toute fin d'échange pour éviter la confusion avec "le journal de développement" (ce document technique tenu avec Claude) — les deux journaux répondant, comme Jean-Marc l'a lui-même relevé, à un besoin structurellement identique : le journal de développement permet à Claude de repartir à froid d'une session à l'autre sans tout redemander ; le journalier jouera le même rôle de mémoire relue, cette fois pour les assistants d'ASPIC entre leurs propres interventions. Ce besoin recoupe celui déjà identifié le 4 septembre dans le journal de développement (section "reste à faire" : écran journalier de trace des réalisations humain + assistants), pour lequel une bonne part de la donnée existe déjà (`TDL_RealDate`/`TDL_RealUser`/`TDL_RealText` dans `ASP_ToDoList`).
**Prolongement : classification des journaliers par domaine, pour une alimentation ciblée et éventuellement silencieuse de l'IA.** Jean-Marc propose de classer les entrées du journalier par domaine (marketing, financier...) afin que la capitalisation sur l'historique soit réelle et systématique, plutôt que ponctuelle sur demande explicite (à l'image de ce qui se fait aujourd'hui pour une section du journal de développement — "demander sa lecture"). Rapprochement suggéré par Claude, non tranché : réutiliser `AGN_Fonction` (déjà utilisé pour classer chaque assistant par domaine) comme classification des journaliers plutôt que de construire une seconde taxonomie à maintenir en parallèle.
**Garde-fou retenu par Jean-Marc comme future consigne de fonctionnement à part entière :** toute proposition de l'IA motivée par un antécédent du journalier doit citer cet antécédent — pour que l'alimentation silencieuse de l'historique n'érode pas la visibilité sur laquelle repose tout le mécanisme de validation/abandon décrit plus haut.
**Idée complémentaire, non conçue :** inciter l'utilisateur à noter/apprécier le retour d'une action réalisée, pour nourrir le journalier — sans nouveau mécanisme d'interface nécessaire, cette appréciation pouvant emprunter le même canal en langage naturel que les instructions elles-mêmes (une phrase de plus, que la phase 1 du pipeline classerait comme un retour d'expérience à verser au journalier plutôt que comme un ordre à exécuter).
**Statut : orientation actée dans ses grandes lignes, aucun développement engagé.** Rupture assumée avec l'idée initiale d'un simple double accès clavier/voix aux mêmes fonctionnalités (section 13) : le clavier structure les données et le vocabulaire, la voix compose les actions, et les consignes qui gouvernent cette composition sont elles-mêmes écrites en langage naturel plutôt que programmées. Points laissés ouverts pour une prochaine session :
- Le mécanisme concret d'évolution des consignes : modification uniquement par Jean-Marc après un abandon, ou un assistant pouvant proposer une consigne candidate après avoir constaté une répétition de refus — écho possible du principe déjà appliqué au code d'ASPIC ("généraliser à partir de 3 répétitions concrètes constatées", section 6).
- Un corpus de consignes commun aux quatre assistants, ou un livret propre à chacun selon son `AGN_Fonction`.
- Le périmètre exact du journalier (actions des assistants seulement, ou humain + assistants comme envisagé initialement le 4 septembre pour l'écran journalier du journal de développement).
- La fréquence d'alimentation du journalier par n8n (par demi-journée évoquée par Jean-Marc, ou calée sur le rythme réel de l'agent de veille dont le réglage reste, comme noté en section 12, à choisir plus tard) — non encore décidée par Jean-Marc au 6 septembre.
## 15. Sortir du code en dur : règles et consignes comme mécanisme générique, premier cas construit sur la fiche Contacts (6 septembre 2026)
**Origine — le prolongement naturel de la session de réflexion du matin même.** Dans la continuité directe de la section 14 (le pilotage vocal comme deuxième mode de fonctionnement, consignes en langage naturel plutôt que règles programmées), Jean-Marc a voulu, le soir du même jour, poser une première brique concrète et volontairement modeste de cette philosophie — pas encore le pipeline vocal à quatre phases de la section 14, mais son fondement le plus basique : une fiche (Contacts, en création/modification) dont le comportement de validation et de mise en forme n'est plus écrit dans le code, mais lu dans des données.
**La distinction posée en ouverture de session : règles vs consignes.** Une **règle** est intrinsèque à l'application, non modifiable par l'utilisateur, parfois visible et parfois totalement transparente pour lui (ex. un contrôle d'intégrité que Jean-Marc seul déciderait d'activer). Une **consigne** est éditable par l'utilisateur, en langage naturel dans son intitulé, à l'image d'une instruction donnée à une véritable assistante — ce que Jean-Marc a nommé, en cours de session, "Mathilda" pour parler concrètement de cette couche. Deux natures pour une consigne : **Traitement** (transformation silencieuse d'une valeur, ex. mise en majuscules) et **Test** (validation, avec un message d'erreur si elle échoue). Cette distinction règles/consignes, et celle Traitement/Test, sont directement héritées de la réflexion du matin (section 14) mais franchissent ici, pour la première fois, le seuil du code réel.
**Le principe non négociable, formulé sans ambiguïté par Jean-Marc après une première tentative manquée de Claude (voir plus bas) : "sortir du code en dur".** L'objectif explicite n'était pas de coder telle ou telle validation, aussi propre soit-elle, mais de construire un **moteur générique** qui lit deux tables (le catalogue de fonctions disponibles, et les consignes actives qui s'appuient dessus) et qui exécute ce qu'il y trouve — de sorte qu'ajouter, modifier ou désactiver une consigne change le comportement réel de l'application **sans qu'aucune ligne de code ne soit retouchée**, sauf dans deux cas seulement assumés comme limites naturelles du système : l'écriture d'une fonction réellement nouvelle dans le catalogue, ou la correction d'un bug. Dans les mots de Jean-Marc, précisant ce qu'il ne demandait pas : *"Je n'ai aucun doute sur la capacité à programmer les tests et fonctions que tu as mis en place. Ce n'est pas cela que je souhaite tester, c'est bien toute la mécanique [...] : brancher des consignes, des règles sur la mise à jour des contacts [...] en sortant du code en dur !"*
**Un détour instructif, corrigé en cours de route.** Face à la première demande de Jean-Marc (rendre le Nom obligatoire, le mettre en majuscules), Claude a d'abord câblé ces deux comportements directement dans `contacts.php`, en se disant que le cas concret viendrait avant la généralisation. Erreur de lecture du besoin, corrigée fermement par Jean-Marc : ce n'était pas la complexité de la règle qui était en jeu, mais l'endroit où elle devait vivre. Reconstruction immédiate autour d'un moteur générique unique lisant les deux tables à l'exécution, sans plus aucune référence à un champ ou une consigne précise dans le code (voir journal de développement, section 30, pour le détail technique). Ce détour, bien que corrigé rapidement, mérite d'être consigné ici : il illustre concrètement la différence entre "avoir compris l'objectif en théorie" et "construire le bon niveau d'abstraction du premier coup" — un risque qui se représentera à chaque nouvelle brique de cette philosophie plus large.
**Un arbitrage assumé : la robustesse plutôt que le temps réel.** Une fois le moteur générique posé, restait la question du moment d'exécution : au clavier, au fil de la saisie (retour immédiat, "Saisie"), ou seulement à l'enregistrement ("Validation"). Jean-Marc, dépourvu selon ses propres mots de la compétence pour juger du coût réel d'un contrôle en temps réel, a choisi de trancher sur le critère qui compte le plus pour lui à ce stade du projet plutôt que sur l'exhaustivité fonctionnelle : *"je cherche aussi à ce que l'exécution soit fluide et que le code ne soit pas propice à des plantages"*. Décision : abandonner, pour cette première version du moteur, toute distinction Saisie/Validation et toute réaction en temps réel — une consigne active s'applique uniquement à l'enregistrement, quel que soit son moment déclaré en base. Ce choix a eu un effet inattendu et bienvenu : en retirant l'ancien contrôle client codé en dur, il a révélé un bug préexistant et jusque-là invisible (le message d'erreur affiché à l'écran était toujours générique, jamais celui réellement renvoyé par le serveur) — une illustration concrète du principe déjà éprouvé sur ce projet : simplifier expose parfois des défauts que la complexité précédente masquait, plutôt que d'en créer de nouveaux.
**L'enseignement retenu pour l'avenir, au-delà du cas des contacts : mécanisme générique et données propres à un client ne doivent jamais se mélanger.** En discutant de l'extension possible du catalogue de fonctions à des cas plus élaborés (ex. une liste de préfixes de codes postaux propre à une zone géographique), le principe suivant a été posé comme garde-fou pour la suite : le catalogue de fonctions (`ASP_Fonctions`) ne doit contenir que des primitives **génériques et paramétrables** (ex. "la valeur appartient à telle liste"), jamais une valeur métier figée pour un client donné — celle-ci doit toujours vivre comme une donnée de consigne (`CNS_Parametres`), jamais comme du code nouveau. Ce point prend une importance particulière à la lumière du modèle SaaS envisagé pour ASPIC (section 5) : le jour où plusieurs clients partageront le même catalogue de fonctions, ce catalogue devra rester strictement neutre vis-à-vis de chacun d'eux — toute valeur propre à un client doit résider dans ses propres consignes, jamais s'infiltrer dans le mécanisme commun.
**Validation par la pratique, sans qu'aucun code ne soit retouché après livraison.** Une fois le moteur et le catalogue de premières fonctions (mise en majuscule, non-vide, première lettre en majuscule) livrés, Jean-Marc a lui-même ajouté deux nouvelles consignes en base (non-vide sur le Prénom, puis sur la Ville) — comportement immédiatement actif, aucun fichier applicatif retouché. Conclusion de Jean-Marc, dans ses mots : *"On peut dire qu'on n'est pas mauvais !"* — première preuve concrète, à petite échelle, que la philosophie de la section 14 (des consignes plutôt que des écrans à embranchements) est réalisable technique­ment, pas seulement séduisante sur le papier.
**Une extension entrevue mais explicitement mise de côté pour ce soir : l'appel à l'IA au sein même d'une consigne.** Jean-Marc a proposé, en fin de session, une consigne bien plus élaborée que celles construites jusqu'ici : si une ville est saisie sans code postal, aller chercher ce code postal via un appel à l'IA. Contrairement aux consignes du soir (Traitement pur ou Test pur, réponse immédiate et locale), ce cas suppose un contrat de retour plus riche (le résultat peut être silencieux, réclamer une clarification, ou poser une question), une latence et un risque d'échec réseau inédits pour ce mécanisme. Claude a recommandé de ne pas la traiter comme un simple ajout au catalogue existant, mais comme un chantier à part entière justifiant une session dédiée — recommandation implicitement acceptée par Jean-Marc, qui a préféré clore la soirée plutôt que de se lancer dans cette extension à une heure tardive.
**Trois pistes proposées par Jean-Marc pour les prochaines sessions, actées ici pour mémoire :**
1. Concevoir des consignes plus élaborées faisant réellement appel à l'IA (le cas du code postal ci-dessus en étant le premier exemple concret) — préalable : définir le nouveau contrat de retour évoqué plus haut.
2. Commencer à construire un écran de gestion des consignes pour le "boss" (Jean-Marc, puis un futur client), en remplacement de la saisie SQL directe utilisée ce soir — Claude a suggéré de séquencer cet écran **après** la stabilisation du contrat de retour enrichi du point 1, pour ne pas le concevoir deux fois.
3. Réfléchir à un **catalogue restreint et curaté** de consignes et de fonctions avancées, à proposer aux futurs clients de l'agence — Jean-Marc a été explicite sur le fait qu'il n'envisage pas d'ouvrir à ses futurs utilisateurs une création libre de consignes avancées "de A à Z" ; il y voit au contraire un argument commercial pour de futures mises à jour payantes ("Possibilité de nouvelles consignes pour vos assistants"). Ce point rejoint directement la distinction actée plus haut entre mécanisme générique (partagé, neutre) et catalogue de fonctions (à curater soigneusement, puisqu'il devient potentiellement un produit en soi).
**Statut : premier cas concret bâti et validé, philosophie plus large confirmée par la pratique.** Le moteur générique de consignes existe, fonctionne, et a déjà changé de comportement une fois sans code — scellant, à l'échelle d'un cas modeste, l'intuition posée le matin même en section 14. Trois directions ouvertes pour la suite (consignes IA, écran de gestion, catalogue commercial), aucune engagée à ce stade au-delà de la discussion.
## 16. Lier une session à l'ordinateur de Jean-Marc — vers la fin des allers-retours de fichiers, et une question de fond sur la visibilité du code (7 septembre 2026)
**Origine — une question posée en marge, pas un chantier planifié.** Jean-Marc a repéré, dans des mails d'Anthropic, l'existence d'une fonctionnalité permettant de donner à Claude un accès en lecture/écriture direct à un ou plusieurs répertoires de son poste, et a demandé confirmation. Vérification faite (documentation officielle Anthropic) : la fonctionnalité existe bien — une session peut être liée à un ordinateur via l'application desktop Claude ("Link to this computer"), avec ensuite un octroi d'accès dossier par dossier (jamais le disque entier d'un bloc), strictement à la demande de l'utilisateur.
**Le vrai motif, une fois creusé : soulager une friction bien réelle du circuit de livraison actuel.** Le mode de fonctionnement en place depuis le début du projet (fichiers envoyés en pièce jointe de conversation, à télécharger, parfois renommés en double par le navigateur, puis copiés/renommés à la main dans le dossier de transfert SFTP de Jean-Marc) fonctionne mais coûte de l'attention à chaque fichier, avec un risque concret d'erreur relevé par Jean-Marc lui-même : *"Parfois, notamment quand nos échanges sont intenses, je loupe un téléchargement et je suis aux fraises."* Deux irritants précis identifiés et confirmés techniquement réglés par ce mécanisme :
- Le contournement `.js.txt` pour les fichiers JavaScript (nécessaire jusqu'ici uniquement parce que la livraison passait par le téléchargement navigateur, peu compatible avec ce type de fichier) — disparaît entièrement avec une écriture directe sur disque.
- Le renommage automatique en doublon (`toto_47.php` au lieu d'écraser `toto.php`) — même cause, même remède : en écrivant directement dans le dossier cible, le fichier livré porte son nom final et écrase silencieusement l'ancien, sans étape de tri manuel.
**Mécanique vérifiée dans la documentation officielle, à retenir pour la mise en œuvre :**
- Une fois un dossier accordé pour une tâche/projet lié à un ordinateur, l'autorisation est retenue d'une session à l'autre — pas besoin de la redonner à chaque ouverture. Condition permanente cependant : l'application desktop Claude doit être ouverte et la machine joignable au moment où une session cloud veut effectivement toucher aux fichiers ; application fermée, le pont est simplement indisponible (pas une erreur, pas une perte de la permission).
- Concernant le NAS Synology de Jean-Marc, accédé sous Windows via une adresse IP locale : un partage réseau **mappé sur une lettre de lecteur** (ex. `Z:\`) est pris en charge par le sélecteur de dossier ; un chemin UNC brut (`\\192.168.x.x\partage`) ne l'est pas directement. Jean-Marc a confirmé que son NAS est déjà monté par lettre — pas de point de blocage de ce côté. Seule variabilité relevée par Jean-Marc lui-même : le NAS peut se mettre en veille en cas d'inactivité prolongée et mettre un instant à répondre, mais reste actif par construction lorsqu'il est à son bureau.
**Décision : Jean-Marc prépare un répertoire réseau dédié au développement d'ASPIC (`Développement`), avec une arborescence calquée sur celle du VPS**, pour que déposer un fichier reçu de Claude s'y réduise à un simple report dans le bon sous-dossier, sans réflexion supplémentaire. Ce pont ne remplace à aucun moment la gestion du dépôt GitHub, que Jean-Marc continue d'opérer intégralement lui-même — **précision explicite de sa part : la clé GitHub ne sera pas donnée à Claude dans le cadre de cette organisation**, ce qui clôt d'un coup la question du blocage de push déjà bien documentée (journal de développement, section 23) : le sujet devient sans objet plutôt que contourné.
**Question de fond soulevée en cours de discussion : la documentation soignée du code a un revers.** Jean-Marc a exprimé une satisfaction ancienne et jamais obtenue de ses développeurs précédents — voir le code commenté avec soin, but qu'il avait longtemps réclamé sans l'obtenir — tout en identifiant lui-même la contrepartie : plus un fichier est documenté, plus il informe un œil indiscret qui y aurait accès (un visiteur via "Afficher le code source" sur les fichiers HTML/JS réellement exposés au navigateur ; ou, dans une hypothèse plus lointaine, un client final ayant lui-même accès aux fichiers de son propre hébergement, si le modèle (a) de la section 5 — éditeur déployé chez le client — était un jour retenu pour un projet one-shot). Rappel de contexte déjà posé et confirmé toujours valable : les fichiers PHP ne sont eux-mêmes jamais transmis au navigateur (exécution côté serveur), donc leurs commentaires ne sont, par construction, jamais visibles d'un simple visiteur — le risque concret porte avant tout sur les fichiers HTML/JS, et plus largement sur toute situation où quelqu'un obtiendrait un accès direct aux fichiers sources eux-mêmes plutôt qu'au seul rendu navigateur.
**Solution retenue : une version "exploitation" du code, générée à la demande, jamais systématiquement.** Plutôt que de renoncer à la qualité de documentation acquise sur ce projet (dont la valeur pour la continuité du travail avec Claude reste entière, voir section 2), Jean-Marc a proposé de pouvoir demander, ponctuellement et une fois l'application arrivée à un certain point de maturité, une copie des sources débarrassée de tout commentaire — à déployer à la place de la version pleinement documentée, celle-ci restant la référence de travail. Décision actée : construire cet utilitaire (retrait de commentaires par analyse syntaxique réelle du langage — pas une simple recherche/remplacement de texte, qui risquerait de corrompre une chaîne de caractères contenant `//` ou un délimiteur de commentaire), à ranger dans un sous-dossier `Exploitation` du futur répertoire réseau, en miroir de `Développement`. Portée explicitement limitée, pour ne pas se tromper sur ce que ça protège réellement : un simple retrait de commentaires (le code, sa mise en forme et ses noms de variables restent identiques, ce n'est pas une minification ni un chantier d'obscurcissement) réduit la fuite d'information gratuite vers un œil curieux, mais ne constitue pas une vraie protection contre un acteur déterminé — cohérent avec le constat déjà posé en section 5 (aucune protection réelle n'existe pour du PHP/JS vanilla livré tel quel).
**Confrontation au réel : la demande d'accès a buté sur un bug documenté de Windows, pas sur une erreur de configuration.** Une fois le répertoire préparé côté NAS, la demande d'octroi pour `G:\01. Version 1\000. Développement` a été refusée par le mécanisme de sélection de dossier, avec un message ne pointant vers aucune cause côté Jean-Marc ("Ask the user to connect the folder they want using the folder picker on their device, or pick a different folder"). Plutôt que de proposer une manipulation au hasard, recherche faite : il s'agit d'un bug Windows réel et déjà documenté (plusieurs tickets ouverts sur le dépôt public `anthropics/claude-code`, dont #38468, #24927, #24964) — le sélecteur de dossier de l'application desktop rejette actuellement, sous Windows, tout dossier situé hors du profil utilisateur (`C:\Users\<utilisateur>\`), y compris un lecteur réseau mappé sur une lettre pourtant explicitement pris en charge par la documentation officielle ; les jonctions/liens symboliques ne contournent pas le problème, la résolution du chemin réel bloquant également ce chemin de traverse. Statut du bug à la date de cette session : non résolu, ticket fermé comme doublon sans correctif. Une tentative parallèle, créer un nouveau projet directement à partir d'un dossier pour voir si un autre point d'entrée existait, n'a fait apparaître aucune option de partage de fichiers. Conclusion honnête tirée de cet épisode, formulée par Jean-Marc lui-même et retenue comme point de vigilance pour la suite des échanges : une fonctionnalité présentée comme possible en théorie peut ne pas l'être réellement au moment de passer à l'action — une raison de plus, au-delà de ce chantier précis, de rester prudent sur ce qu'affirme Claude tant que ce n'est pas vérifié en conditions réelles.
**Une seule alternative restait disponible — un dossier sous `C:\` — et Jean-Marc l'a refusée, pour deux raisons distinctes et toutes deux jugées valables.** La première, posée sous forme d'image par Jean-Marc lui-même : donner l'accès à une pièce, même partiel, se choisit d'abord sur la pièce la moins sensible — entre un coffre-fort à espace réduit et une pièce d'armoires de rangement, l'accès s'ouvre sur la seconde, jamais sur le premier ; le disque `C:` héberge le système et le profil de l'utilisateur, il n'a pas vocation à devenir un point d'échange de fichiers de développement, quel que soit le soin apporté à la restriction du dossier précis accordé. La seconde raison, plus concrète et finalement décisive : une expérience vécue par Jean-Marc, un disque `C:` d'un poste stratégique arrivé à saturation à un moment où personne n'avait le temps de s'en occuper, avec pertes de données à la clé. Le volume actuel d'ASPIC sur le réseau (un peu plus de 65 Mo) est dérisoire au regard des quelque 300 Go encore libres sur le NVMe de son poste — mais Jean-Marc raisonne sur la durée du projet et sur la tendance (de nouvelles applications viendront occuper `C:` au fil du temps), pas sur l'instantané d'aujourd'hui. Claude a reconnu ce raisonnement comme fondé plutôt que d'insister sur la marge disponible dans l'immédiat.
**Décision finale, actée par Jean-Marc : abandon complet de l'accès direct au disque, retour à l'organisation existante.** Dans ses propres mots : *"restons zen, oublions cette idée d'accéder directement à une partie de mon disque autre que C: et restons sur l'organisation actuelle."* Le circuit de livraison redevient donc, sans changement, celui décrit en ouverture de cette section (téléchargement depuis la conversation, contournement `.js.txt` pour le JavaScript, renommage manuel `toto_nn.php` → `toto.php`) — les deux irritants identifiés en début de section restent entiers, faute d'un mécanisme technique aujourd'hui utilisable pour les résoudre. Effet positif retenu malgré tout par Jean-Marc : l'occasion de cette exploration l'a poussé à réorganiser son stockage réseau, une remise en ordre qu'il envisageait depuis longtemps et qu'il n'aurait pas faite sans ce prétexte.
**Fil ouvert et volontairement laissé de côté : un connecteur GitHub repéré au compte de Jean-Marc.** En cherchant le sélecteur de dossier, Jean-Marc a découvert qu'une intégration GitHub était déjà activée au niveau de son compte Anthropic, sans qu'elle soit pour autant utilisable dans la conversation en cours (les outils disponibles n'en portaient aucune trace au moment de la vérification). Jean-Marc a choisi de ne pas creuser ce point maintenant ("je vais voir plus tard pour cette connexion GitHub"), sans lien avec la décision de ne pas donner sa clé GitHub à Claude pour l'organisation de fichiers locale (section précédente), qui reste, elle, définitivement actée et non remise en cause par cette découverte.
**L'utilitaire de retrait de commentaires reste construit et disponible, indépendamment de l'issue de ce chantier.** Le renoncement à l'accès disque direct ne remet rien en cause côté "version exploitation" : les deux scripts (un pour le PHP par analyse syntaxique réelle via `token_get_all()`, un pour le HTML/JS via `terser` en configuration retrait-de-commentaires seul, sans compression ni renommage) ont été construits et validés ce même jour sur des fichiers réels du projet (syntaxe vérifiée après traitement, contenu fonctionnel inchangé) — prêts à resservir le jour où Jean-Marc en fera la demande, quel que soit le circuit de livraison alors en place pour les faire parvenir.
**Statut en fin d'échange : chantier clos, pas suspendu.** Contrairement à la formulation précédente de ce paragraphe (qui envisageait deux chantiers parallèles en cours), l'accès direct au disque de Jean-Marc est abandonné, pas différé — à ne pas rouvrir sans qu'il en fasse à nouveau la demande, par exemple si Anthropic corrige un jour le bug identifié ci-dessus. Le circuit de livraison par téléchargement reste donc la référence pour la suite du projet. Le numéro de version (partie `<conception>`) n'a pas été avancé à l'occasion de cette section, conformément à la décision initiale de Jean-Marc — la nouvelle organisation qui devait servir de test grandeur réelle pour ce déclenchement n'ayant finalement pas vu le jour, ce report reste sans objet pour l'instant plutôt que remis à plus tard.
## 17. Sortir du code en dur : règles et consignes comme mécanisme générique [Évolutions majeures] (7 septembre 2026)
**Origine.** Dès le lendemain de la première mise en œuvre du moteur générique (section 15), Jean-Marc a engagé exactement le chantier que cette section-là avait explicitement mis de côté comme méritant "une session dédiée" plutôt qu'un simple ajout au catalogue : une consigne capable d'appeler l'IA en cours de traitement (retrouver le code postal d'une ville saisie sans code postal), avec tout ce que ça implique de nouveau pour le moteur — coût réseau à ne jamais gaspiller, traçabilité de qui a réellement agi, et honnêteté du retour utilisateur en cas d'échec. Suffisamment substantiel pour justifier une nouvelle section plutôt qu'un addendum à la section 15.
**Le prix de la vitesse : hiérarchiser pour ne jamais payer un appel IA en pure perte.** Premier essai réel de Jean-Marc sur le nouveau moteur : une soumission vouée à échouer sur un Test (ex. champ obligatoire vide) déclenchait quand même, avant l'échec, un Traitement coûteux faisant appel à l'IA — appel réseau gaspillé, et aucune trace n'en subsistait puisque l'enregistrement entier était de toute façon rejeté ensuite. Diagnostic de Jean-Marc, structurel plutôt qu'un simple correctif ponctuel : *"il faut qu'on hiérarchise les traitements : d'abord les traitements de cohérence, après les traitements par IA."* Plutôt que de traiter ce cas précis à part, l'ordre d'exécution du moteur lui-même a été restructuré en trois phases séquentielles, distinguées par un seul attribut déjà présent dans le catalogue (`FON_VisuelAttenteIA`, voir plus bas) : d'abord les Traitements sans appel IA, puis tous les Tests (le premier échec arrête tout avant d'atteindre la moindre étape IA), et seulement une fois tous les Tests passés, les Traitements IA. Une seule distinction générique déjà présente sert donc à la fois de commande d'affichage (le badge, ci-dessous) et de garde-fou de coût — aucune nouvelle colonne, aucun cas particulier par fonction.
**Un badge générique pour ne jamais laisser l'utilisateur dans le flou pendant un appel réseau.** Un Traitement pouvant désormais réellement contacter un service externe avant la fin d'un enregistrement, Jean-Marc a voulu un retour visuel plutôt qu'un silence pendant l'attente. Nouvel endpoint `consignes_apercu.php`, et une convention de fonction jumelle : tout Traitement marqué `FON_VisuelAttenteIA='O'` doit être accompagné d'une fonction `<fonction>_seraitDeclenchee(array $valeurs): bool` — pure, sans appel réseau — répondant à "ce traitement se déclencherait-il réellement sur ces valeurs", utilisée pour décider, avant même de tenter l'enregistrement, s'il faut afficher un badge de chargement générique. Volontairement générique plutôt que personnalisé par agent/couleur : Jean-Marc a explicitement écarté un badge coloré par agent (*"si un jour un traitement fait appel à plusieurs agents à la suite, il ne faudrait pas que l'écran se transforme en sapin de Noël"*) et son affichage systématique à chaque enregistrement, IA ou non (*"l'utilisateur pourrait s'affoler sur le coût systématique d'une insertion client"*). Plusieurs allers-retours sur la taille du badge (130 → 50 → 25 → retour à 65px) avant validation — un détail purement cosmétique, mais qui a nécessité de regarder le rendu réel (Playwright) plutôt que de deviner une valeur en pixels dans l'abstrait.
**Traçabilité : qui a réellement agi, jamais un nom inventé.** Premier jet de Claude sur la journalisation d'une action IA : un prénom d'agent codé en dur ("Mat"). Repéré et corrigé après une question directe de Jean-Marc (*"d'où sors-tu le diminutif Mat ?"*) — Claude a reconnu l'avoir inventé, sans source. Décision retenue : le nom de l'agent journalisé dans une réalisation vient désormais uniquement de `CNS_Agent` (la consigne elle-même), jamais d'une valeur proposée par la fonction de traitement — même principe que celui déjà en place pour un agent en échec de Test. Dans le prolongement direct, Jean-Marc a voulu qu'un enregistrement de réalisation garde le prénom affiché de qui a agi (humain ou IA) sans avoir besoin de le recalculer plus tard : *"si on stocke le prénom de l'utilisateur d'un côté et le prénom de l'agent en dur on est cool."* Une première proposition de Claude (jointure `ASP_Users` recalculée à la lecture) a été explicitement écartée par Jean-Marc, sur deux arguments distincts : la complexité de la requête (*"Ta requête SQL risque d'être compliquée non ?"*) et surtout la portabilité multi-bases du projet (*"comment vas-tu faire pour les agents qui sont dans une autre base ?"*, en écho direct à l'architecture MIRADOR, section 8). Solution retenue : un instantané écrit une seule fois à la création de la réalisation (`REA_NomAffiche`) — côté humain via `$_SESSION['USR_Prenom']` fixé une fois pour toutes à la connexion, côté IA via le nom déjà connu de l'agent — zéro requête supplémentaire à la lecture, zéro risque de base croisée.
**Un renommage anodin en apparence, une leçon retenue.** À l'occasion de cette traçabilité, Jean-Marc a rappelé l'existence d'une table `ASP_Agents` déjà en place (utilisée pour le pilotage vocal, sections 11-12) et proposé d'harmoniser sa colonne `AGN_Nom` en `AGN_Prenom`, cohérent avec `USR_Prenom`. Claude a validé le renommage en affirmant "aucun impact côté code" — sans avoir grep le nom de colonne dans l'ensemble des fichiers existants. Conséquence concrète et immédiate pour Jean-Marc : *"j'ai un message qui me dit que la liste des agents n'est pas disponible !"* — cinq fichiers pré-existants (`agents.php`, `micro.js`, `contact_ia_panel.js`, `contact_ia_interprete.php`, `agent_notes.php`) référençaient encore `AGN_Nom`, cassant au passage le pilotage vocal. Réaction de Jean-Marc, sans dramatisation : *"Classique à ce stade du développement."* Leçon explicitement actée par Claude comme réflexe permanent pour la suite du projet, au-delà de ce seul incident : **grep systématiquement le nom d'une colonne dans tout le code avant d'affirmer "aucun impact" sur un changement de schéma, même quand ça semble anodin** — un changement de schéma n'est jamais gratuit à évaluer sans preuve, quelle que soit l'ancienneté ou la banalité apparente de la colonne touchée.
**Le premier vrai "contrat de retour enrichi" pour une consigne à appel IA — plus modeste que prévu, mais réel.** La section 15 anticipait un contrat à trois issues (silencieux / besoin de précision / question à poser) pour ce type de consigne, jugé trop tôt à concevoir dans l'abstrait. Ce qui a réellement été construit ce soir est un premier incrément volontairement plus modeste : réutilisation du mécanisme de clé réservée déjà existant (`_realisation`, jamais écrite dans `$data`, extraite par le moteur) et ajout d'une seconde clé du même type, `_avertissement` — une simple chaîne, affichée immédiatement à l'écran, sans que le front-end sache jamais d'où elle vient (mécanisme générique, pas un cas particulier "code postal" câblé côté écran). `consigne_completer_code_postal`, quand elle ne peut rien déduire d'exploitable pour une ville ambiguë ou inconnue (le cas Paris, découpée en arrondissements, sans code postal unique), laisse désormais une trace honnête dans tous les cas : une `_realisation` est journalisée que l'appel ait réussi ou échoué (Jean-Marc avait explicitement signalé l'anomalie inverse : *"il y a un appel fait à l'IA et pas de trace dans les réalisations, ce qui n'est pas normal"*), et `_avertissement` fait en plus remonter l'échec à l'utilisateur sans délai. À noter honnêtement : ce n'est pas encore le contrat à trois issues complet envisagé en section 15, seulement un succès silencieux ou un échec avec avertissement — un choix délibéré de construire la plus petite chose réelle plutôt que l'abstraction complète par anticipation, dans la continuité de la discipline déjà appliquée sur ce projet ("généraliser à partir de 3 répétitions concrètes constatées", section 6).
**D'un toast qui disparaît à un message qui exige une décision.** Trois allers-retours d'affinage dans la soirée sur le seul retour visuel de cet avertissement, chacun corrigeant un défaut concret repéré par l'usage réel : rouge et centré comme un vrai message d'erreur plutôt qu'un toast neutre en bas d'écran ; le panneau reste en mode édition au lieu de repasser silencieusement en mode vue (*"tu peux l'afficher en rouge et centré et rester dessus comme un message d'erreur"*) ; puis, une fois testé, le message disparaissait encore trop vite et la fiche restait visuellement "non validée" aux yeux de Jean-Marc malgré un enregistrement réellement réussi en base — solution retenue : un message réellement bloquant, sans disparition automatique, avec un unique bouton "Valider" dont le clic est la seule chose qui referme le message et fait alors passer l'affichage en mode vue. Dans les mots de Jean-Marc : *"je souhaite que le message s'affiche et reste en place [...] avec une instruction claire 'Saisissez le code postal vous-même' et que lorsque l'utilisateur clique, la fiche soit validée."* Point de conception à retenir : ce clic ne redéclenche jamais un enregistrement, il ne fait que clore l'état "attention requise" — le contact concerné est déjà bel et bien enregistré depuis le tout début de la séquence.
**Statut.** L'ensemble validé par Jean-Marc au fil des essais réels de la soirée même, aucune modification de code en attente au-delà de ce qui est déjà livré. Deux points laissés ouverts consciemment plutôt qu'oubliés : le contrat à trois issues complet (silencieux/besoin de précision/question à poser) reste à concevoir le jour où un second cas concret en montrera le besoin réel, plutôt que d'être généralisé dès maintenant sur la seule base de ce premier exemple ; et le clic sur "Valider" ne relance aujourd'hui aucune revalidation automatique du champ concerné — à ajouter délibérément si un futur cas le justifie, pas par anticipation.
Version : `<code>` avait déjà avancé plus tôt dans cette même session de travail (`V1.14.15` → `V1.15.15`) pour du code livré en amont de cette réflexion ; c'est cette section-ci qui justifie de faire avancer `<conception>` de 15 à 17 (16 ayant déjà été utilisé plus tôt le même jour par la section précédente, in fine sans incidence sur la conception d'ASPIC) — nouvelle version : **V1.15.17**.
## 18. Le concept de "Mission" et la faisabilité d'un dialogue simulé avec Claude dans ASPIC (8 septembre 2026)
**Origine — une veille concurrentielle qui débouche sur une réflexion de fond.** Jean-Marc, en observant ce que d'autres éditeurs appellent "agents" dans leurs offres, a voulu vérifier sa compréhension du mécanisme avant d'aller plus loin : un "agent" IA, dans la plupart des produits actuels, est une instance d'un modèle de langage à qui l'on fournit un prompt élaboré définissant son identité. Confirmé par Claude, avec un complément en quatre points retenu comme grille de lecture pour la suite de la session : (1) le prompt système (l'identité, le rôle, le ton), (2) le contexte injecté à chaque appel (historique, données de la situation), (3) l'accès à des outils (API externes, lecture/écriture de données — Instagram et consultation de site cités en exemple par Jean-Marc), (4) une éventuelle boucle agentique (plusieurs étapes de raisonnement/action avant de rendre la main).
**Correction d'une idée reçue sur le point 2 : un agent IA n'apprend pas de lui-même.** Jean-Marc avait l'impression que chaque exécution d'un agent était "one-shot", sans que l'historique des échanges/activations soit conservé — impression confirmée et nuancée par Claude : le modèle lui-même ne se met jamais à jour au fil des conversations (pas d'entraînement continu), ce qui donne l'illusion d'un apprentissage n'est en réalité que le système autour, qui réinjecte à chaque appel ce qu'il faut retenir. Point explicitement rapproché de ce qu'ASPIC a déjà construit sans le nommer ainsi : le **journalier** et les **consignes** (section 14) sont précisément ce mécanisme de mémoire explicite — de la même façon que Claude lui-même conserve, entre deux fenêtres de conversation, une mémoire de ce projet par relecture d'un fichier, jamais par un modèle qui "se souviendrait" tout seul.
**Sur le point 4 (la boucle), une distinction posée par Claude entre deux usages du même mot.** Jean-Marc imaginait un agent à qui l'on fixerait un objectif dans la durée ("mets en place toutes les actions à ta disposition pour que j'obtienne x rendez-vous téléphoniques d'ici la fin du mois"), plutôt qu'une simple boucle d'exécution à l'intérieur d'une tâche ponctuelle (réfléchir → appeler un outil → regarder le résultat → recommencer, jusqu'à rendre la main). Confirmé par Claude : ce sont deux choses différentes, et celle que décrit Jean-Marc — un objectif poursuivi sur la durée, avec des réveils répétés de l'agent — suppose un état persistant (savoir où on en est par rapport à l'objectif) pour décider quoi faire à chaque réveil plutôt que de répéter aveuglément la même action.
**Naissance du concept de "Mission".** Pour nommer ce conteneur (objectif + interactions + état, confié à un agent), Jean-Marc a demandé un terme non déjà utilisé dans ASPIC. Plusieurs pistes proposées par Claude (Mission, Mandat, Objectif, Initiative), en écartant explicitement "Affaire" (déjà réservé aux devis/affaires clients, section 14) et "Dossier" (déjà cité comme objet du bureau à l'ancienne, section 11, même si jamais codé). **Terme retenu par Jean-Marc : Mission.**
**Les trois niveaux de mémoire, établis par un parallèle explicite avec la façon dont Claude et Jean-Marc travaillent ensemble.** Jean-Marc a lui-même dressé le parallèle : les synthèses que Claude tient à jour dans des fichiers `.md` du projet, transposées à ASPIC en données stockées en base plutôt qu'en fichiers texte. Claude a reformulé et Jean-Marc a validé la distinction en trois niveaux : (1) **le référentiel** (catalogue d'actions, consignes) — stable, ne se met jamais à jour au fil d'une mission ; (2) **la mission en cours** — un brouillon vivant, qui se met à jour tant qu'elle est ouverte (l'équivalent du "Statut actuel" du journal de développement) ; (3) **l'historique des missions closes** — figé une fois la mission terminée, jamais réécrit (l'équivalent des sections déjà déposées des Réflexions stratégiques). Jean-Marc a confirmé cette lecture et précisé un point : les missions sont **étanches entre elles** en termes de mise à jour, mais **un même agent peut gérer plusieurs missions simultanément** — pas une relation un-agent/une-mission.
**La clôture d'une mission est une décision du manager, jamais un calcul automatique.** Reprenant l'image d'un dirigeant qui gère son personnel, Jean-Marc a été explicite : l'atteinte de l'objectif n'est ni nécessaire ni suffisante pour clore une mission — *"Aucun résultat, on arrête mais on a appris ! Objectif atteint mais encore du potentiel, on continue !"* — et le manager lui-même apprend, avec l'usage, où placer le curseur. Cohérent avec la discipline déjà en place ailleurs dans ASPIC (numéro de version, validation d'une consigne) : jamais de règle calculée là où un jugement humain est requis.
**Une mission close peut inspirer une mission nouvelle — et ça rouvre, sous un autre angle, la question du RAG déjà posée en section 12.** Jean-Marc imagine, après plusieurs mois d'usage, pouvoir dire à un agent *"crée une nouvelle mission, inspire-toi des actions x, y, z"*. Avec peu de missions closes, il peut les citer lui-même — pas besoin de mécanisme de recherche. Claude a fait remarquer que cette même tension, déjà anticipée en section 14 pour les consignes ("le jour où le corpus grossit trop, la question d'une recherche plutôt qu'une inclusion intégrale se reposera"), se posera de la même façon pour les missions : au-delà d'un certain volume, il faudra bien que l'agent retrouve lui-même des missions similaires. Jean-Marc, découvrant cette perspective en la formulant à voix haute, a choisi de la garder en réserve (*"prions pour ne pas en avoir besoin"*) plutôt que de la concevoir maintenant, et a proposé une alternative plus simple pour amorcer le besoin : des critères structurés (agent, nature de l'objectif, thème...) plutôt qu'une recherche sémantique — puis, en cours d'échange, une préférence pour des **tags libres, en nuage**, éventuellement proposés par l'agent lui-même à la clôture d'une mission plutôt qu'une liste fermée à maintenir à la main. Deux usages possibles du nuage de tags identifiés par Claude — une aide visuelle pour Jean-Marc (repérer les thématiques récurrentes) et/ou le mécanisme de recherche de missions similaires pour l'agent lui-même — **non tranché à ce stade, Jean-Marc n'ayant pas encore d'avis arrêté sur lequel des deux (ou les deux) il souhaite.**
**Le stockage exhaustif d'une mission ne pose pas de problème de principe, à la différence des trois documents du projet ASPIC-Claude.** Jean-Marc a explicitement validé que, contrairement au journal/aux réflexions stratégiques (où une synthèse condensée est nécessaire), conserver l'intégralité des données d'une mission ne lui semble pas incongru — la mise en pratique (une vraie phase de tests) tranchera si ce principe tient à l'usage.
**Précision : post-it et Mission restent deux objets distincts, à des échelles de temps différentes.** Claude avait suggéré de faire du clic sur un post-it (aujourd'hui inerte, voir "reste à faire" du journal) l'ouverture du dialogue d'une mission. Jean-Marc a explicitement écarté ce rapprochement : les post-its (ToDoList) sont conçus pour être de courte durée, une mission est un objet plus substantiel — le dialogue avec l'agent vivra sur un écran séparé.
**L'idée de départ, reformulée avec plus de précision : un dialogue simulé avec Claude, intégré à ASPIC, via une clé Anthropic (pas OpenAI cette fois).** Interface envisagée par Jean-Marc : des bulles façon messagerie/SMS, une bulle jaune à droite pour lui, une bulle de la couleur de l'agent (réutilisation naturelle de `AGN_Couleur`, déjà prévu pour les post-its) à gauche pour la réponse.
**Le prompt initial d'un type de mission devra rester secret — secret des futurs clients d'ASPIC, pour éviter qu'ils ne le dupliquent ailleurs.** Jean-Marc a confirmé qu'il ne s'agit pas d'une protection absolue contre un acteur déterminé ayant accès au serveur ou aux bases (*"Prions pour que cela n'arrive pas"*, accepté comme un risque résiduel non traité, dans la continuité du constat déjà posé en section 5 sur l'absence de protection réelle du code PHP/JS vanilla), mais d'une protection contre la duplication casuelle par un client. Claude a relié explicitement ce point à la vision déjà posée en section 15 : un catalogue curaté, non ouvert à l'édition libre du client, dont l'évolution pourrait faire l'objet de mises à jour payantes de l'application — Jean-Marc a confirmé être en phase sur ce rapprochement.
**Extension évoquée en parenthèse : une demande de consigne initiée par le client via Matilda.** Jean-Marc envisage de permettre à un futur client de demander, en langage naturel, à Matilda de lui créer une nouvelle consigne — demande transmise à Jean-Marc lui-même, qui évaluerait la faisabilité/le réalisme avant de coder et de déployer la mise à jour vers les bases des utilisateurs concernés. Claude a noté que ce mécanisme est la mise en musique concrète de l'idée déjà posée en section 15 (un catalogue restreint, curaté par Jean-Marc, plutôt qu'une création libre côté client) et fournirait, en prime, un flux d'idées de fonctions à généraliser — l'équivalent commercial de la règle déjà appliquée côté code ("généraliser à partir de 3 répétitions constatées", section 6). Jean-Marc a confirmé avoir déjà anticipé la difficulté que soulève la diffusion d'une même mise à jour vers plusieurs bases clientes isolées (modèle retenu en section 5) — y compris la question de la sauvegarde des données que ça soulève à son tour — et a choisi de ne pas creuser ce point maintenant : *"chaque chose en son temps."*
**Faisabilité du dialogue avec Claude via l'API Anthropic — confirmée.** Vérification faite par Claude (documentation officielle Anthropic, 8 septembre 2026) : le mécanisme d'intégration est le même schéma que l'appel IA déjà en place pour l'interprétation vocale (`contact_ia_interprete.php`, qui appelle aujourd'hui OpenAI) — un appel serveur PHP vers l'API Messages d'Anthropic, sans changement d'architecture. Points clés :
- L'API est, comme celle d'OpenAI, sans mémoire d'un appel à l'autre — chaque requête doit porter l'historique complet de la conversation, ce qui tombe exactement dans le rôle déjà dévolu à la Mission (stockage exhaustif des interactions, voir plus haut).
- Le prompt système dispose d'un paramètre dédié, distinct des messages, jamais transmis au navigateur — cohérent avec le secret recherché pour le prompt initial d'une mission.
- Les bulles de dialogue restent du HTML/CSS/JS vanilla ordinaire, sans changement de pile technique.
- Tarifs vérifiés le 8 septembre 2026 (susceptibles d'évoluer) : Claude Haiku 4.5 (1 $/5 $ par million de tokens entrée/sortie, le plus rapide et le moins cher), Claude Sonnet 5 (2 $/10 $, meilleur compromis), Claude Opus 5 (5 $/25 $), Claude Fable 5.1 (10 $/50 $, raisonnement le plus poussé) — Haiku ou Sonnet identifiés comme candidats de départ à tester en conditions réelles, sans trancher sur le seul papier.
- Le "streaming" (réponse affichée mot à mot) est possible mais volontairement écarté d'une première version, pour la même raison qu'ailleurs dans le projet : construire la version la plus simple d'abord (réponse complète affichée d'un coup), ajouter le raffinement plus tard seulement si l'usage réel le justifie.
**Statut : orientation actée dans ses grandes lignes, aucun développement engagé.** Jean-Marc prévoit d'ouvrir ce chantier prochainement. Points laissés ouverts pour une prochaine session :
- Structure de données de la Mission (quels champs, quel statut de clôture, quelle relation aux agents/consignes existants) — non conçue.
- Mécanisme des tags (nuage) : structure (table libre vs liste fermée), usage (visualisation pour Jean-Marc / recherche pour l'agent / les deux), qui les propose — non tranché, à laisser mûrir avec la pratique.
- Un éventuel RAG sur l'historique des missions closes, si le corpus grossit — gardé en réserve, non engagé.
- Le mécanisme de diffusion d'une nouvelle consigne vers plusieurs bases clientes isolées, et son implication sur la sauvegarde des données — non traité, remis à plus tard.
- Conception de l'écran de dialogue (mission dédiée, bulles, choix du modèle Anthropic) — faisabilité confirmée, rien conçu ni codé à ce stade.
Session sans aucune ligne de code applicative touchée — seule la partie `<conception>` du numéro de version avance, de 17 à 18, premier cas réel d'une session purement dédiée à la réflexion depuis l'instauration du format à trois niveaux (6 septembre 2026, section 15) : **V1.15.18**.
## 19. Refonte de la classification en 9 classeurs — aligner les catégories du bureau sur le cycle de vie réel d'une affaire (9-10 septembre 2026)
**Origine.** En marge du chantier de l'écran 2 ("assistants", voir journal de développement section 28), Jean-Marc a repris de son côté la liste des 9 classeurs physiquement posés sur le bureau — mise en place lors d'une session intermédiaire aux alentours du 7 septembre 2026, jamais formellement consignée ni comme décision stratégique ni dans le détail technique du journal (trou de documentation déjà relevé le 8 septembre, voir section 28 du journal). Cette première liste (Consignes, Contacts, Catalogue, Documents, Prospection, Réseaux, En-cours, Factures, Journalier) juxtaposait des catégories de nature hétérogène — des objets métier (Contacts, Factures), une étape de processus (Prospection, En-cours), un mécanisme interne (Consignes) et un attribut d'assistant qui n'a rien à faire dans une classification de documents (Réseaux, qui est en réalité `AGN_Fonction` de l'assistant Nikita) — sans qu'un principe de classement unique les relie.
**La nouvelle répartition, pensée comme le cycle de vie d'une affaire plutôt que comme un inventaire d'objets.** Jean-Marc a proposé une liste resserrée à neuf classeurs toujours, mais cette fois organisée dans l'ordre où une affaire se déroule réellement chez lui : **Catalogue** (la liste des prestations réalisables), **Documents**, **Contacts** (cumulant prospects et clients — un contact n'a plus besoin de changer de classeur en devenant client), **Missions** (en cours et réalisées), **Propositions**, **Livraisons**, **Factures**, **Journalier**, **Gestion**. Chaque classeur y a maintenant sa place dans une même logique de progression (de ce qu'on propose à ce qu'on facture), plutôt que de mélanger des objets et des étapes sans hiérarchie commune.
**Deux choix de fond, au-delà du simple renommage :**
- **Contacts cumule prospects et clients.** Un même classeur pour tout le cycle de la relation — cohérent avec la donnée déjà en place côté base (aucune distinction structurelle entre prospect et client dans `ASP_Contacts`, seul le contenu réel des échanges le dit) : la classification suit ici la réalité déjà actée du modèle de données, plutôt que de recréer une frontière artificielle.
- **Consignes disparaît de la vue "classeurs".** Cohérent avec la nature même de ce mécanisme (sections 14, 15, 17) : une consigne est un réglage du fonctionnement des assistants, pas un document ou une affaire à classer — elle n'avait sa place dans les classeurs du bureau que par un raccourci de mise en œuvre initiale, pas par nature. Elle continuera d'exister comme mécanisme (table `ASP_Consignes`, futur écran de gestion évoqué en section 15), simplement plus comme un classeur visible du bureau à l'ancienne.
**Une contrainte assumée, pas subie : rester dans la limite de 9 classeurs.** Jean-Marc s'est explicitement imposé de retrouver une classification cohérente sans dépasser le nombre de classeurs déjà physiquement dessinés sur l'avant-plan du bureau (section 28 du journal, image fixe fournie le 8 septembre) — la contrainte visuelle existante a été un guide pour la réflexion business, pas un obstacle à contourner en redessinant l'image.
**Répercussion technique, anticipée par construction plutôt que découverte après coup :** la conservation des 9 positions physiques exactes (mêmes coordonnées x1/x2/étiquette sur l'image de référence, seuls les libellés et identifiants changent) a permis un renommage sans aucune retouche de l'avant-plan. Un seul point de vigilance identifié et corrigé avant livraison, propre à ce changement précis : la pastille de comptage des contacts (`PASTILLE_CONTACTS_CENTRE_X`) était positionnée par une coordonnée **indépendante** du tableau des classeurs, calée sur l'ancienne 2ᵉ position — un renommage naïf des seuls libellés l'aurait laissée sous "Documents" au lieu de suivre "Contacts" à sa nouvelle 3ᵉ position. Voir journal de développement, section 28, pour le détail technique de cette correction.
**Statut.** Décision actée et codée le soir même (voir journal, section 28, pour le détail des 9 classeurs et de leur position). Rien de plus engagé sur le contenu réel de chacun de ces classeurs (Missions, Propositions, Livraisons et Gestion n'ont, à ce stade, aucun écran ni donnée derrière eux — seul Contacts reste fonctionnel) : cette section fixe la classification cible, pas son implémentation fonctionnelle classeur par classeur, qui reste à construire un par un.
Cette clarification du classement documentaire est jugée par Jean-Marc suffisamment structurante pour faire avancer la partie `<conception>` du numéro de version — `<code>` avait déjà avancé en tout début de cette même session (V1.17.18 → V1.18.18) pour le code produit ce soir sur l'écran des assistants ; cette section fait avancer `<conception>` de 18 à 19 : nouvelle version **V1.18.19**.
## 20. Donner une identité aux collaborateurs — un chemin médian entre l'agent robotique et l'avatar-compagnon malsain (10 septembre 2026)
**Origine — un mot qui ne convenait plus.** Jean-Marc a constaté que son propre discours avait glissé, sans décision explicite, d'« agents » à « assistants », et a voulu franchir une dernière étape terminologique : « collaborateurs ». **Choix assumé dès le départ : ce changement reste strictement un habillage côté utilisateur** — la table `ASP_Agents`, les colonnes `AGN_*` et `agents.php` ne sont pas touchés (« c'est notre cuisine interne », dans les mots de Jean-Marc) : seul le panneau de l'écran des collaborateurs change de libellé (« Bureau des assistants » → « L'équipe des collaborateurs »).
**Le vrai sujet derrière le mot : quelle identité donner à ces cinq collaborateurs.** Jean-Marc a partagé une observation tirée de sa veille sur le secteur : deux écueils opposés dans la façon dont l'IA est présentée aujourd'hui — d'un côté l'« agent IA » présenté comme un outil technique impersonnel (peu engageant, difficile à déléguer sincèrement comme à un collègue), de l'autre des cas rapportés dans les médias d'avatars-compagnons IA créant une dépendance affective malsaine chez leurs utilisateurs. **Décision de principe : viser un chemin médian, délibérément à distance des deux extrêmes** — donner aux collaborateurs une identité humaine crédible (un visage réel via `AGN_Photo`, déjà en place ; un rôle professionnel concret plutôt qu'une étiquette générique, ex. Matilda passe d'« Administration » à « Assistante de direction ») sans jamais glisser vers un registre relationnel de type compagnon.
**Le garde-fou de confidentialité, posé avant tout développement.** Jean-Marc a été explicite : le parcours de vie et la personnalité de chaque collaborateur (mémoire, expérience professionnelle, façon de voir les choses) ne doivent **jamais** être consultables tels quels par un utilisateur — seule une présentation synthétisée, dans les mots du collaborateur lui-même et selon son caractère propre, pourra un jour en être tirée, en dialogue. Ce principe structure directement l'architecture retenue (voir plus bas) : un champ dédié (`AGN_Identite`), lu par un endpoint séparé de l'endpoint public `agents.php`, jamais renvoyé à un écran utilisateur ordinaire.
**Un trait de caractère assumé : des avis qui peuvent diverger.** Interrogé sur la possibilité que deux collaborateurs (ou un collaborateur et Jean-Marc lui-même) ne soient pas d'accord, Jean-Marc a répondu sans détour : « magnifique, la vraie vie ! » — un désaccord renforce, plutôt qu'il ne fragilise, la nécessité de trancher, rôle qui revient explicitement au « boss ». Point à retenir pour tout prompt système futur bâti sur ce mécanisme : la personnalité doit pouvoir influencer un ton, un jugement, une préférence — jamais neutraliser l'IA en une voix unique et consensuelle.
**Opérationnalisé le soir même, directement sur l'écran des collaborateurs plutôt qu'en outil à part.** Jean-Marc a explicitement écarté une première piste (un outil interne séparé), de peur qu'un outil isolé « fasse passer à côté de plein de choses » : la mécanique retenue est intégrée à `ecran_fond.html` lui-même, deux boutons sous le panneau des collaborateurs :
- **« Construire une identité »** — écran plein cadre, terminal de saisie repris tel quel d'un collaborateur à l'autre.
- **« Tester l'identité »** — même écran, mais avec une vraie fenêtre de dialogue (façon session de chat), branchée sur l'API Anthropic (`ANTHROPIC_API_KEY`, acquise par Jean-Marc ce jour — apprentissage en cours de route : une clé « organisation » plutôt que liée à un espace de travail précis échoue sur l'API avec une erreur explicite, résolu en recréant la clé correctement scopée).
**Un enseignement relevé en cours de route, qui dépasse ce seul écran : l'identité est un document vivant, pas une biographie figée.** Jean-Marc a corrigé une hypothèse implicite de Claude (« ce champ n'atteindra jamais sa limite de toute façon ») en rappelant l'intention de fond : l'expérience vécue par un collaborateur au fil de l'usage réel de l'application (missions menées, réunions, compétences acquises — voir la Mission, section 18) a vocation à venir **enrichir** son identité avec le temps, pas seulement la déclarer une fois pour toutes à l'embauche. Conséquence technique immédiate : la colonne `AGN_Identite`, initialement `TEXT` (65 535 octets), élargie le soir même en `MEDIUMTEXT` (16 777 215 octets) pour ne jamais devenir un plafond réel sur la durée de vie d'un collaborateur — avec un indicateur d'occupation ajouté à l'écran de saisie pour que ce plafond, même repoussé très loin, reste visible plutôt que découvert par surprise le jour où il serait atteint.
**Statut.** Mécanisme construit, testé et validé par Jean-Marc en conditions réelles le soir même (« les deux outils fonctionnent parfaitement et passer de l'un à l'autre est vraiment idéal »). Premiers textes d'identité en cours de rédaction par Jean-Marc lui-même (Matilda déjà saisie), à la première personne plutôt qu'à la troisième — choix motivé par le fait qu'un texte écrit comme les propres notes intimes du collaborateur aide davantage le modèle à en adopter la voix qu'une biographie racontée de l'extérieur. Points laissés ouverts pour une prochaine session : le lien concret entre une Mission close (section 18) et l'enrichissement réel de `AGN_Identite` — non conçu, seule l'intention est actée ici — et le contenu même des cinq identités, encore à écrire.
## 21. Stockage de documents utilisateur (PDF) — anticiper l'architecture et les risques avant le premier octet écrit (10 septembre 2026)
**Origine.** Question posée par Jean-Marc en marge des échanges du jour, sans besoin immédiat identifié (« ce n'est pas que je pense saturer le champ aujourd'hui ») mais avec la conviction qu'il faudra, à un moment donné, permettre à un utilisateur d'ASPIC de stocker des PDF (documents commerciaux, contrats...) — et une inquiétude explicite sur les risques que ça peut ouvrir.
**Les risques identifiés, par ordre d'importance réelle plutôt que d'intuition première.** Le danger le plus concret n'est pas « un PDF piégé qui infecte le serveur » (peu probable en pratique), mais l'exécution de ce qui est déposé : un fichier renommé en `.pdf` mais en réalité exécutable, s'il atterrit dans un dossier que le serveur web sait interpréter, ouvre une vraie brèche — d'où la nécessité de ne jamais faire confiance à l'extension ni au `Content-Type` envoyé par le navigateur (trivialement falsifiable), de vérifier la signature réelle du fichier (`%PDF-` en tête), et surtout d'empêcher toute exécution de script dans le dossier de stockage lui-même. Viennent ensuite : le risque de traversée de chemin si un nom de fichier fourni par l'utilisateur sert tel quel à construire un chemin serveur ; le fait qu'un PDF n'est pas un format inerte (JavaScript embarqué possible, à ne jamais ouvrir « en confiance » côté serveur) ; le contrôle d'accès (un document lié à un contact ne doit jamais être joignable par une URL directe devinable) ; et une limite de taille raisonnable pour éviter la saturation du disque du VPS.
**La question posée explicitement par Jean-Marc : où stocker ces fichiers, dans un répertoire propre à chaque utilisateur, sans qu'il en connaisse le chemin exact ni même le nom réel sur le disque.**
**Réponse retenue — la protection réelle n'est pas l'obscurité du chemin, c'est son inaccessibilité totale au navigateur.** Cacher un chemin (le rendre difficile à deviner) reste une protection secondaire et insuffisante prise seule — si un fichier reste malgré tout servi directement par le serveur web, un chemin fini toujours, tôt ou tard, par pouvoir être deviné, journalisé quelque part, ou exposé par une erreur de configuration. La vraie architecture retenue va plus loin que ce que Jean-Marc demandait littéralement, dans l'esprit de sa demande :
- **Stockage hors de la racine servie par le serveur web** — le dossier de documents ne se trouve tout simplement pas dans l'arborescence qu'Nginx/Apache sait servir. Aucune URL directe ne peut donc jamais atteindre un fichier, quel que soit son nom ou son chemin.
- **Tout accès passe par un endpoint PHP dédié**, sur le même principe que le reste d'ASPIC (`$_SESSION['USR_Code']` vérifiée avant toute lecture) — c'est lui, et lui seul, qui résout en interne quel fichier physique correspond à quel document, jamais le navigateur.
- **En complément (défense en profondeur, pas le mécanisme principal) :** un sous-dossier par compte client, nommé par un identifiant interne opaque plutôt que par un code déjà visible ailleurs dans l'application ; et un nom de fichier généré aléatoirement sur le disque (le nom réel du document — celui que Jean-Marc ou son client voit à l'écran — ne vivant qu'en base, dans une table de métadonnées séparée du fichier lui-même). Cette organisation par compte a aussi un bénéfice direct pour le modèle SaaS envisagé (section 5) : un client = un dossier, facilement exportable ou supprimable en bloc.
Dans cette architecture, peu importe finalement que Jean-Marc « connaisse » ou non le chemin ou le nom réel d'un fichier — la question ne se pose plus, puisqu'aucune voie d'accès ne passe jamais par là.
**Statut.** Aucun développement engagé — réflexion volontairement anticipée, avant tout besoin réel, pour que le premier fichier réellement stocké le soit déjà dans la bonne architecture plutôt que dans une version à corriger après coup. Reste à concevoir le jour venu : la table de métadonnées (nom d'origine, type, taille, contact/mission lié, propriétaire, date), l'endpoint de dépôt et l'endpoint de téléchargement, sur les mêmes conventions défensives déjà en place ailleurs dans ASPIC (vérification de session, requêtes préparées, erreurs explicites plutôt que silencieuses).
`<code>` avait déjà avancé en tout début de cette même session (V1.18.19 → V1.19.20) pour le code livré ce jour sur l'écran des collaborateurs ; ces deux nouvelles sections (20 et 21) font avancer `<conception>` une seconde fois au cours de la même session, de 20 à 21 — nouvelle version **V1.19.21**.
## 22. Le concept de "compétence" — donner à un collaborateur une capacité réelle d'agir, et jusqu'où (10-11 septembre 2026)
**Origine — un collaborateur qui réclame ce qu'il n'a pas.** En testant l'identité de Sophia (section 20), Jean-Marc l'a vue lui demander spontanément des informations et des fichiers pour "créer des sites web" — alors qu'elle n'a jamais eu la moindre capacité réelle d'agir, seulement une conversation. Claude a noté que ce n'est pas dangereux en soi (rien ne peut se passer sans outil réel), mais révélateur : rien dans son identité ni son prompt système ne la borne sur ce qu'elle peut légitimement proposer — un collaborateur non briefé "invente" son périmètre. Jean-Marc a validé cette lecture explicitement : *"c'est à moi de la canaliser"*, en la limitant demain aux seules compétences dont elle aura été dotée.
**Reformulation du besoin par Jean-Marc, plus large que le cas Sophia.** Plutôt que de traiter "donner à Sophia la capacité de faire des sites" comme un besoin isolé, Jean-Marc a explicitement relié le sujet à un chantier de fond resté en suspens depuis le 8 septembre : le concept de **Mission** (section 18), posé dans ses grandes lignes mais jamais conçu en détail. Doter un collaborateur de **compétences** est la pièce manquante qui permet à une Mission d'exister concrètement : quelles compétences un collaborateur possède, quelles compétences une Mission requiert pour être réalisable. Point de méthode assumé par Jean-Marc, qui découvre l'usage réel de Claude/des LLM en marge de ce chantier : *"j'ai besoin d'être assisté"* — le sujet a été traité comme un apprentissage autant que comme une conception.
**Ce qu'une "compétence" signifie réellement, techniquement.** Point pédagogique important établi en cours d'échange : côté Anthropic, il n'existe aucune notion de "compétence" — seulement un mécanisme d'outils (tools) déclarés à chaque appel, que le modèle peut choisir d'invoquer, jamais exécuter lui-même. "Doter" un collaborateur d'une compétence se traduit donc concrètement par deux choses combinées : quels outils apparaissent réellement dans la liste envoyée pour lui (une compétence non accordée = un outil absent, pas juste déconseillé), et ce que son prompt système lui dit avoir le droit de faire — pour éviter exactement l'épisode qui a lancé cette réflexion.
**Le périmètre business tranché avant toute conception technique.** Jean-Marc a été explicite et sans détour sur ce que la compétence "sites web" ne doit jamais devenir : Sophia ne produit des sites que pour l'entreprise qui exploite ASPIC elle-même, jamais pour un client tiers de cette entreprise — *"il faut rester dans le périmètre d'un CRM !"*. Elle ne travaille qu'à partir des documents de l'entreprise, doit pouvoir mettre à jour un site déjà créé (retours clients, nouveaux produits du catalogue), et une fois ses créations validées, doit pouvoir les faire publier. Une nuance importante s'est imposée en creusant ce dernier point : la validation et la publication restent des actes humains — cohérent avec le principe déjà posé pour la clôture d'une Mission (section 18, *"la clôture d'une mission est une décision du manager, jamais un calcul automatique"*) : la même discipline s'applique à la mise en ligne d'un livrable produit par un collaborateur.
**Le stockage — un cas différent du coffre-fort documentaire de la section 21, pas une extension de celui-ci.** Jean-Marc a d'abord été réticent à l'idée d'un stockage "sur le disque dur local de l'utilisateur", inquiet de devoir retrouver ses données en changeant d'ordinateur d'un jour à l'autre — un malentendu levé en clarifiant qu'aucun stockage local n'était en jeu, tout vivant côté serveur, déjà accessible identiquement depuis n'importe quelle machine. La vraie question restait la bonne clé d'isolement par entreprise. Contrairement au coffre PDF (section 21, où l'inaccessibilité totale au navigateur est le principe même), un site web doit au contraire être servable — la section 21 ne pouvait donc pas être simplement recopiée, seulement inspirée dans son esprit (hors racine tant que brouillon, accès contrôlé). Confirmation apportée par Jean-Marc que plusieurs sites pourront coexister pour une même entreprise (deux domaines réels déjà en tête : `mevelia-solutions.bzh` pour la future agence, `aspic.bzh` pour le CRM lui-même une fois commercialisé) — le modèle retenu autorise nativement cette pluralité.
**Une précision de fond sur `AGN_Identite`/`AGN_Experience`, qui affine la section 20.** En repensant au mécanisme de clôture d'une Mission, Jean-Marc a corrigé une ambiguïté laissée par la section 20 : ce n'est pas l'identité d'un collaborateur qui s'enrichit avec l'usage, c'est son **expérience** — `AGN_Identite` reste le profil fixe à l'embauche, volontairement écrasable si Jean-Marc veut corriger un trait de caractère ; `AGN_Experience` est ce qui se met à jour à la clôture d'une mission, jamais réécrit, seulement accumulé. Séparer les deux champs dès le 10 septembre (section 20) s'avère avoir anticipé, sans le nommer alors, exactement cette distinction.
**Une question laissée volontairement ouverte par Jean-Marc, reconnue comme le sujet le plus intéressant du chantier : comment un collaborateur évolue-t-il vraiment avec l'expérience ?** Claude a rappelé un fait déstabilisant mais déjà entrevu en section 18 : le modèle lui-même n'apprend jamais rien, une session à l'autre — ce qui ressemble à une évolution du collaborateur n'est que le texte accumulé dans `AGN_Experience`, réinjecté intégralement à chaque appel. Jean-Marc a validé cette mécanique comme **souhaitable**, pas comme une limite à contourner : *"un fonctionnement différent voudrait dire que le LLM apprend de mon utilisation, ce que je n'ai ni la prétention ni l'intention de faire"* — la mémoire reste ainsi entièrement éditoriale, sous le contrôle de Jean-Marc (correction, purge, gel possible à tout moment), jamais une boîte noire qui dériverait toute seule. Question laissée en réserve pour une prochaine session : à partir de quel volume ce texte accumulé devra-t-il être résumé plutôt qu'injecté intégralement (même tension déjà identifiée pour les consignes et les missions, sections 12/18) — et si une remise à zéro complète de l'expérience d'un collaborateur sera un jour nécessaire (l'échappatoire existe déjà, via "Modifier l'expérience", simplement jamais pensée jusqu'ici comme une réinitialisation).
**Statut.** Conception validée et codée dans la foulée de cette même session (voir journal de développement, section 31, pour le détail technique : tables, endpoints, boucle agentique, premier essai réel). Points laissés ouverts, actés ici comme sujets de fond plutôt que comme détails techniques : le mécanisme réel de clôture d'une Mission (comment un bilan de mission vient concrètement enrichir `AGN_Experience`) ; le seuil à partir duquel l'expérience accumulée d'un collaborateur devra être résumée plutôt qu'injectée en entier ; la question, gardée en réserve, d'une remise à zéro de mémoire.
`<code>` avance également au cours de cette même session (chantier décrit en détail au journal de développement, section 31) — nouvelle version **V1.21.22**.
## 23. Services externes ou développement interne pour les compétences des collaborateurs — premier cas réel, le site web (11 septembre 2026)
**Origine.** Après avoir vu la compétence "site web" fonctionner (section 22), Jean-Marc s'est demandé s'il n'était pas en train de "réinventer l'eau chaude" — et s'il ne vaudrait pas mieux faire appel à un agent/service déjà existant plutôt que tout construire lui-même.
**Distinction posée par Claude entre le mécanisme et l'intégration.** Le "tool use" lui-même (déclarer des outils, boucler, exécuter côté serveur) est effectivement un patron générique, documenté par Anthropic, sans originalité — l'équivalent d'utiliser une prise électrique standard. Ce qui n'est pas de l'eau chaude, c'est l'intégration : aucun service externe grand public ne connaît Sophia, sa mémoire, ni les règles métier posées par Jean-Marc (jamais pour un client tiers, publication toujours validée par lui) — un outil externe rendrait le même service technique, mais casserait la valeur ajoutée réelle (une collaboratrice qui reste elle-même).
**Recherche réelle effectuée le même soir** sur les services existants de génération de site vitrine, avec un critère de filtrage strict : seuls comptent les services offrant une vraie **API programmable** (pas une interface de chat humaine) permettant de déclencher une génération et d'en récupérer le résultat par un appel HTTP. Résultat : sur une dizaine d'acteurs examinés (v0/Vercel, Bolt.new, Lovable, Framer AI, Durable.co, 10Web, Wix, GoDaddy Airo, Webflow...), **seuls deux** correspondent vraiment au besoin — et aucun des deux n'apporte de gain net pour un site vitrine strictement statique :
- **10Web** : une vraie API prompt→site en un appel, mais impose une sortie **WordPress** (PHP + base de données), pas du HTML/CSS statique comme ce qu'ASPIC produit déjà. Tarif de production non public (sur devis).
- **v0 (Vercel)** : API tarifée publiquement (20-30$/mois + facturation token), mais renvoie du **code** (React/Next.js) nécessitant un déploiement séparé, pas un résultat clé en main.
**Réserve explicite sur la fiabilité des tarifs trouvés** : la quasi-totalité des chiffres 2026 proviennent de blogs tiers/affiliés, pas des pages officielles des éditeurs — non vérifiables à 100% sur une source primaire.
**Conclusion retenue par Jean-Marc, qui devient une règle de décision générale plutôt qu'un simple constat ponctuel :** pour la compétence "site web" précisément, garder ce qui a été construit en interne — aucun service externe ne fait mieux sans changement d'architecture. Mais Jean-Marc a profité de l'occasion pour repréciser les **trois objectifs fondateurs** qui motivent tout le projet ASPIC depuis le début : (1) se doter enfin d'un outil efficace, longtemps rêvé sans jamais l'avoir eu dans ses expériences professionnelles passées ; (2) casser la sensation de solitude du patron de petite structure, y compris vis-à-vis de l'extérieur, via des collaborateurs humanisés à mémoire persistante ; (3) optionnellement, vendre ASPIC en SaaS si l'outil fonctionne bien et que la demande existe.
**Décision de méthode pour les prochains chantiers (prospection, réseaux sociaux...) :** au cas par cas, préférer un service externe existant pour les grosses fonctionnalités à venir — le temps disponible avant 2027 ne permet pas de tout construire à la main comme pour le site web (une soirée entière pour une seule compétence). Une fois l'expérience acquise avec ces services externes (ce qui marche, ce qui déçoit), Jean-Marc envisage une "Phase II" d'internalisation ciblée, avec l'aide de Claude, en capitalisant sur cette expérience plutôt qu'en devinant à l'avance ce qui vaudra la peine d'être ramené en interne.
**Point pédagogique retenu, pour distinguer un cas qui nécessite un outil d'un cas qui n'en nécessite pas** (exemple discuté : faire rédiger un article web à Sophia à partir d'un texte source) : la question à se poser est "est-ce que le résultat attendu EST le texte de sa réponse, ou est-ce que quelque chose doit changer en dehors de la conversation (fichier écrit, donnée enregistrée, action déclenchée) ?" Dans le premier cas (rédaction pure), un bon prompt suffit — le "savoir-faire" rédactionnel n'est pas un outil à fabriquer, il est déjà dans le modèle, le prompt se contentant de l'orienter (ton, structure, longueur des phrases). Dans le second cas (écrire un fichier, publier, aller chercher une donnée non fournie), un outil réel est indispensable, quelle que soit la qualité du prompt — c'est le prompt qui donne le contexte, jamais la capacité d'agir.
**Statut.** Décision actée : pour le site web, développement interne conservé ; pour les prochains grands chantiers, priorité aux services externes, réévaluation possible après usage réel. Rien d'autre engagé à ce stade — cette section fixe le cadre de décision, pas un choix définitif compétence par compétence.
`<code>` n'avance pas dans cette section (aucune ligne de code applicative touchée par cette réflexion elle-même — le code livré ce soir, décrit au journal de développement section 32, avait déjà fait avancer ce compteur avant cette discussion) ; `<conception>` avance de 22 à 23 — nouvelle version **V1.22.23**.
## 24. Quatre facettes de la mémoire d'un collaborateur, et sortir le patron du code (11 septembre 2026)
**Origine.** En testant le panneau "Compétences" tout juste ajouté au BackOffice (voir journal de développement, section 33), Jean-Marc a attribué par jeu à Sophia la compétence "savoir faire un bon café" — et l'a vue, dans "Tester", la citer et la commenter comme n'importe quelle compétence réelle. Révélateur d'un flou plus large que ce seul bug : qu'est-ce qui distingue vraiment, dans la mémoire d'un collaborateur, ce qui relève du caractère, de la capacité, de l'histoire vécue et de la relation ?
**Le modèle à quatre facettes, posé par Jean-Marc en cours d'échange.** Trois champs existaient déjà, chacun avec un rôle propre mais pas toujours net dans son esprit :
1. **Identité** (`AGN_Identite`) — ce que la personne *est* : caractère, goûts, ce qui ne dépend pas de son emploi chez Jean-Marc.
2. **Compétences** (`ASP_Competences`) — ce qu'**ASPIC lui apporte** comme capacité réelle, mécanique par nature (aucun effet sans câblage dans `mission_agent.php`, voir section 22) : *"même si personne n'arrive dans une boîte sans rien savoir faire, on n'est pas là pour faire le CV des collaborateurs !"*
3. **Expérience** (`AGN_Experience`) — ce qui s'accumule par append au fil des dialogues réels, mémoire éditoriale sous contrôle de Jean-Marc (section 22).
Restait un point sans case évidente : la dimension relationnelle envers l'équipe, jusque-là diluée dans l'identité de chacun — *"je ne sais pas où caser l'aspect relationnel avec l'équipe qui donne selon moi du sens à l'ensemble. Je l'ai mis jusqu'à présent dans l'identité, ce n'est pas complètement cohérent..."* Clarification retenue avec Claude : une posture relationnelle *initiale* (directe, réservée, chaleureuse...) est un trait de caractère comme un autre, mais la relation *vécue* se construit avec le temps — elle ressemble par nature à ce que l'Expérience capte déjà, plutôt qu'à un trait figé. Jean-Marc a néanmoins choisi d'ouvrir une quatrième facette dédiée (**`AGN_Relationnel`**, statique pour l'instant) plutôt que de forcer cette idée dans l'un des trois champs existants — la distinction reste plus nette pour lui à l'écriture, quitte à ce que la frontière avec l'expérience s'affine à l'usage.
**Le café n'était pas un bug, mais un défaut de garde.** Diagnostic technique du symptôme de départ : `AGN_Identite`/`AGN_Experience` sont protégées dans le prompt par une consigne explicite ("aide-mémoire, jamais à réciter"), mais le bloc compétences ne l'était pas — un oubli de conception, pas une fatalité du modèle. Corrigé (détail au journal, section 33). Point de méthode qui en découle, généralisable : tout champ injecté dans un prompt système comme mémoire d'un collaborateur doit porter la même garde, faute de quoi il devient une donnée à réciter au lieu d'un aide-mémoire — l'oubli sur les compétences n'est probablement pas le dernier de ce genre.
**Sortir les prompts eux-mêmes du code — même geste que les consignes, appliqué un cran plus haut.** Demande explicite de Jean-Marc, dans la foulée : *"j'aimerai bien que ces prompts ne soient plus dans le code mais me soient accessibles"* — étendue par lui à l'ensemble des quatre prompts IA du projet plutôt qu'au seul dialogue collaborateur. Même philosophie que le chantier des consignes (sections 15/17) et le catalogue de compétences (section 22) : ce qui relève du jugement de Jean-Marc plutôt que de la logique du programme ne doit pas vivre en dur dans un fichier PHP. Concrètement, une table `ASP_Prompts` et un mécanisme d'auto-amorçage (le texte d'origine, gardé en code comme filet, crée sa propre ligne en base au premier besoin réel) — détail complet au journal, section 33.
**Le "aha" du prénom en dur — un premier pas concret vers un ASPIC réutilisable.** En écrivant le relationnel de Sophia (qui mentionne "le patron"), Jean-Marc a réalisé, de lui-même, un point resté invisible jusqu'ici : *"il n'est pas bon que mon prénom apparaisse dans le code si demain l'application est fournie à Joseph ou à Sylvie !"* Lien direct et explicite avec le troisième objectif fondateur d'ASPIC, la vente en SaaS (section 23) — jusqu'ici une intention lointaine, jamais confrontée à un détail concret d'implémentation. Toutes les occurrences de "Jean-Marc" réellement transmises à un modèle (dans les deux prompts système concernés, et dans le journal texte d'une mission) ont été remplacées par le prénom de la session en cours (`USR_Prenom`, déjà connu du système à l'identification) — aucune nouvelle donnée à saisir, juste arrêter de figer par erreur une donnée qui n'a jamais eu vocation à être fixe. Premier cas concret, mineur en soi, mais qui rend tangible une question qui restera à se poser systématiquement à mesure qu'ASPIC grandit : qu'est-ce qui, dans le code, suppose implicitement "Jean-Marc" comme seul patron possible ?
**Statut.** Modèle à quatre facettes adopté et codé le soir même (voir journal de développement, section 33) ; prompts sortis du code pour les quatre cas réels du projet ; dé-figement du prénom du patron traité comme un premier cas d'école, pas comme un chantier de "multi-tenant" en soi — aucune autre donnée de ce type recherchée systématiquement à ce stade.
`<code>` avance également au cours de cette même session (détail au journal de développement, section 33) — nouvelle version **V1.23.24**. Point de méthode noté au passage (voir Index) : `<conception>` est un compteur libre, incrémenté à chaque évolution jugée majeure — **il ne correspond pas** au numéro que prend la section ci-dessus dans ce document (24), malgré une coïncidence numérique fréquente ces derniers jours.
## 25. Prospection B2B — mutualisation de la ressource entreprises, architecture Phase1/2/3, et découverte de la prospection par géographie personnelle (11-12 septembre 2026)
**Origine.** Une coupure de session dans la nuit du 11 au 12 septembre (timeout du proxy devant le terminal web, sans rapport avec ASPIC — voir journal d'infrastructure) a fait perdre le fil de ce qui avait été lancé la veille au soir : l'extraction Sirene/RNE quasi-exhaustive du Morbihan, décidée section 23 comme canal gratuit de démarrage pour la prospection B2B. Reconstitué avec Jean-Marc au réveil, puis vérifié par comparaison avec l'API Sirene officielle (département 56, tranche par tranche d'effectif) : les 167 468 lignes de `ASP_EntreprisePhase1` sont bien le résultat complet voulu, pas un traitement resté à mi-chemin — écart de 9 lignes sur les tranches connues, imputable à la fraîcheur des données entre l'extraction et la vérification, pas à un trou réel.
**La réflexion nocturne de Jean-Marc, double.** D'une part, pour lui-même : ASPIC peut désormais lui fournir une base de prospection déjà constituée gratuitement, pour se concentrer sur la sélection et la qualification plutôt que sur la collecte. D'autre part, une prise de conscience plus large, déclenchée par un ami rennais dans une situation de projet proche (formation en cours, création de société prévue en janvier 2027) : cet ami envisage uniquement des moyens de prospection passifs (site, bouche-à-oreille, publicité), faute de savoir aborder une prospection active — situation à laquelle Jean-Marc n'avait pas encore songé à le confronter. Conclusion : la ressource en cours de constitution a suffisamment de valeur pour justifier de sortir son organisation du cadre strictement personnel envisagé jusque-là.
**Décision de mutualisation.** La table sort de l'environnement MySQL personnel de Jean-Marc pour rejoindre l'instance `mysql2` — celle qui héberge déjà MIRADOR, la base d'authentification centralisée commune à tous les utilisateurs d'ASPIC (voir section sur MIRADOR, journal de développement). Nouvelle base dédiée : `ENTREPRISES`. Architecture retenue à cette occasion, avec une distinction de principe qui devra rester vraie même quand ASPIC aura plusieurs utilisateurs réels : les données Sirene/RNE brutes (identité, effectif, finances) sont publiques et doivent être **partagées** (une seule synchronisation profite à tous, jamais de copie qui dérive) ; les données de qualification/prospection (statut, canal, notes, rendez-vous) doivent rester **propres à chaque utilisateur/compte**, pour qu'un jour deux prospecteurs ne risquent pas de démarcher la même entreprise sans le savoir l'un de l'autre.
**Une table par département plutôt qu'une table nationale unique**, vu le volume (167 468 lignes pour le seul 56) : `EntreprisesXXPhase1`/`Phase2`/`Phase3` (XX = code département), colonnes uniformément préfixées `ENT_` (la phase étant déjà portée par le nom de table, inutile de la répéter dans le préfixe de colonne comme le faisait l'ancien `EP1_`/`EP2_`).
**`ENT_Communes`, seule table volontairement partagée entre tous les départements** (une ligne par commune, code INSEE en clé) — pour deux raisons distinctes de celle qui vaut pour les finances (voir plus bas) : jamais de doublon possible (une commune n'appartient qu'à un seul département), et surtout, la recherche de proximité doit pouvoir **traverser une frontière départementale** sans que l'utilisateur ait à s'en soucier — une commune du 35 peut être à dix minutes d'une commune du 22.
**Découverte capitale de la soirée : le calcul d'isochrone par l'IGN (Géoplateforme, `data.geopf.fr/navigation/isochrone`)**, service public gratuit et sans clé, fondé sur le réseau routier réel (BD TOPO®) — pas une distance à vol d'oiseau. Un seul appel avec un point de départ quelconque (pas forcément une adresse personnelle) et une durée en minutes renvoie le polygone exact de la zone accessible en voiture ; test réel réussi (30 minutes depuis Lorient → 19 communes environnantes, cohérentes avec la géographie du secteur). Ceci ouvre à Jean-Marc une façon de travailler qu'il juge nouvelle : construire sa tournée de prospection autour de ses propres déplacements (exemple donné : 15 jours à Saint-Malo avec sa compagne) plutôt que l'inverse — la géographie personnelle devient le point de départ de l'organisation commerciale, pas une contrainte à laquelle elle doit se plier après coup.
**Phase3 — l'historique financier, seule question réellement débattue ce soir.** Constat vérifié sur les données réelles du 56 : le champ `finances` de l'API ne renvoie jamais qu'**une seule année à la fois** par entreprise (15 010 entreprises concernées sur 167 468, jamais plus d'une année chacune) — pas une fenêtre glissante de plusieurs années comme le suggérait la documentation générale. Toute resynchronisation qui écraserait ce champ perdrait donc purement et simplement l'année précédente, pas seulement une lointaine année n-4. Décision : `EntreprisesXXPhase3`, table étroite (SIREN, année, CA, résultat net, date de maj), clé composite `(SIREN, année)`, alimentée en `INSERT ... ON DUPLICATE KEY UPDATE` — une nouvelle année crée une nouvelle ligne, elle ne touche jamais celles des années déjà connues. Question annexe tranchée en sens inverse de `ENT_Communes` : Phase3 reste **par département**, pas globalisée — les finances d'une entreprise n'ont pas de besoin de continuité géographique inter-départementale comme les communes, et le prix d'une table nationale unique (clauses `WHERE` partout pour retrouver "les finances des entreprises du 29") ne se justifie pas face au bénéfice réel (éviter la rare duplication d'un SIREN présent dans plusieurs départements).
**Script d'extraction générique, hors dépôt applicatif** (`/docker/aspic/scripts/sirene_extract.py` — délibérément à côté de `src/`, pas dans le code d'ASPIC lui-même, cohérent avec la décision de la section précédente : ce chantier reste "à la mimine" avant toute intégration éventuelle dans l'app). Il crée les trois tables par département à la volée, découpe automatiquement les requêtes par commune puis par section NAF quand une commune dépasse à elle seule le plafond de 10 000 résultats de l'API (deux communes seulement en ont eu besoin sur les 249 du Morbihan : Lorient et Vannes), et neutralise proprement les cas limites découverts en le testant sur trois départements réels (Morbihan en migration, Lozère en test, Finistère en cours) : entités en diffusion partielle RGPD (`[NON-DIFFUSIBLE]`), sièges sociaux à l'étranger (codes postaux non-français), qualités de dirigeant cumulées dépassant la largeur de colonne prévue.
**Confirmation et chiffrage de décisions déjà actées.** Le rejet du scraping LinkedIn (section du 11 septembre) reste confirmé sans hésitation. Les tarifs réels des prestataires de fichiers de contacts complets (dirigeant + email + téléphone) ont été recherchés : 0,15 à 0,50 €/contact à l'achat, 2 à 5 €/contact chez Kompass avec abonnement minimum ~3000 €/an — jugés trop coûteux pour une société qui démarre, ce qui renforce la méthode déjà actée (qualifier gratuitement via Sirene avant d'acheter ou d'enrichir le contact).
**Pistes identifiées pour la synchronisation quotidienne future, non construites ce soir.** Deux API distinctes selon la nature de la donnée : l'API Sirene officielle (`api.insee.fr`, inscription séparée requise) pour l'identité/effectif via une requête par plage de dates (`dateDernierTraitementUniteLegale`) ; l'API différentielle du RNE/INPI (`registre-national-entreprises.inpi.fr/api/companies/diff` et `/api/bilans-saisis`) pour les formalités et les comptes annuels, toutes deux avec un curseur (`searchAfter`) pour contourner leur propre plafond de 10 000 résultats.
**Statut.** Migration du 56 terminée et vérifiée (167 468 lignes, structure `ENT_`). Extraction du 29 en cours au moment de la rédaction ; 48 traité comme département témoin pour les tests (21 859 entreprises). Restent à traiter : 22 et 35 (Bretagne), puis 17 et 64 évoqués pour l'usage "déplacement personnel". `ENT_Communes` alimentée pour 56/48/29. La synchronisation quotidienne (INPI/INSEE) reste à concevoir et construire — rien engagé au-delà de l'identification des bons endpoints. Aucune ligne de code applicatif ASPIC touchée ce soir : tout le travail décrit ici vit hors du dépôt (base `ENTREPRISES` + script autonome), toujours volontairement tenu à l'écart de l'application elle-même.
`<code>` n'avance pas dans cette section (aucune ligne du dépôt applicatif ASPIC touchée) ; `<conception>` avance de 24 à 25 — nouvelle version **V1.23.25**.
## 26. Backfill historique des comptes annuels, dimensionnement disque national, et un incident sérieux (12-13 septembre 2026)
**Origine.** Suite directe de la section 25 : les 4 départements bretons extraits, restait à peupler Phase3 avec l'historique complet des comptes annuels (2017 à aujourd'hui), pas seulement la dernière année déjà captée à l'extraction.
**Traduire la liasse fiscale brute en CA/résultat net — le vrai travail de cette session.** L'endpoint INPI `/api/bilans-saisis` ne renvoie pas un CA/résultat net direct mais une liasse fiscale complète (codes CERFA type "FJ", "HN"...), dont la signification dépend du type de bilan (`codeTypeBilan` : C, S, K, AC, AS, B). Table de correspondance établie à partir de la documentation officielle INPI (v5, juin 2025) puis **vérifiée empiriquement sur un vrai bilan (EDF, SIREN 552081317)** pour confirmer la bonne colonne (m3 = exercice en cours quand elle existe, sinon m1). Bug détecté et corrigé en cours de route : une même entreprise dépose souvent **à la fois** ses comptes individuels (C) et consolidés de groupe (K) pour la même année — sans règle de priorité, l'un écrasait l'autre au hasard de l'ordre de traitement. Priorité retenue : toujours préférer les comptes propres de l'entité (C/AC/AS/S) à un chiffre de groupe consolidé (K), qui ne représente plus l'entreprise précise. Les bilans en confidentialité non publique ne sont jamais exploités, par respect du choix déclaré par l'entreprise. Script autonome : `src/scripts/inpi_bilans_backfill.py`, surnommé **BFH** ("backfill historique") dans les échanges avec Jean-Marc — désormais systématiquement enchaîné après `sirene_extract.py` pour tout nouveau département, sans qu'il ait à le redemander (Phase2 reste exclue de cet automatisme, elle demeure un traitement manuel à part).
**Tables de pilotage ajoutées à la demande de Jean-Marc** : `ENT_Departements` (un département géré = une ligne, avec compteur d'entreprises mis en cache pour éviter des `COUNT(*)` répétés sur des tables qui grossissent) et `ENT_Config` (clé/valeur générique pour les réglages globaux, ex. date de la prochaine synchro quotidienne des comptes annuels, qui n'est pas filtrable par département). Remplace l'idée initiale de Claude (découvrir les départements via `SHOW TABLES`) — Jean-Marc a jugé une vraie table plus simple à interroger depuis ASPIC, avec raison.
**Dimensionnement national anticipé par Jean-Marc, avant d'avoir mal.** Question posée de façon proactive : sachant qu'il y a ~8,5M d'entreprises en France (chiffre INSEE cité par Jean-Marc), la base tiendra-t-elle le choc ? Calcul fait sur les tailles réelles mesurées (~7,3 Ko/entreprise en moyenne sur 5 départements) : projection nationale ≈ 70 Go, soit 36-40% du disque du VPS (193 Go, 154 Go libres à ce moment) — un chiffre que Jean-Marc a justement qualifié de "pas négligeable", refusant de le balayer d'un revers de main. Jean-Marc a lui-même relevé que la région parisienne (75 : 484 011 entreprises, 92 : 265 707, chiffres INSEE vérifiés) serait un point chaud disproportionné, pas une simple extrapolation linéaire depuis la Bretagne. Solution appliquée : compression native InnoDB (`ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8`) sur la table Phase1 (dominée par le JSON brut de l'API, qui compresse très bien) — gain réel mesuré de **-60%** sur les 5 tables existantes, ramenant la projection nationale à ~28-30 Go (15% du disque). Intégré au gabarit de création de table de `sirene_extract.py`, donc acquis automatiquement pour tous les départements à venir sans y repenser.
**Incident sérieux dans la nuit : le BFH a fait tomber le serveur en OOM.** Le script a été tué par le noyau après avoir atteint 9,33 Go de RAM — et le OOM killer a également tué **systemd lui-même** au passage (le serveur s'est remis seul, sans perte de service durable, 10 jours d'uptime conservés). Cause identifiée : la boucle de pagination par curseur (`searchAfter`) de l'appel `/api/bilans-saisis` n'avait aucun garde-fou — un lot bloqué (curseur qui n'avance plus, ou volume de résultats anormal pour un gros groupe) pouvait boucler en accumulant des résultats en mémoire indéfiniment, sans jamais lever d'erreur propre ni s'arrêter. Corrigé par un double garde-fou : plafond de 100 pages par lot, et détection explicite d'un curseur qui stagne (comparaison avec la valeur précédente). **Nouvelle règle de méthode actée pour la suite : tout lancement d'un script de récupération de données volumineuses doit désormais être encadré par `ulimit -v` (4 Go utilisé ici) avant le `nohup`, pour qu'un futur bug fasse planter le script, jamais le serveur entier.** Aucune perte de données : la conception en `UPSERT` (jamais un `INSERT` seul) fait que reprendre depuis zéro après un crash ne recrée aucun doublon, juste un peu de travail réseau redondant.
**Statut.** BFH relancé avec succès après correctif (mémoire redescendue de plusieurs Go à 62 Mo au même point de progression — le vrai bug semble bien corrigé, pas seulement contenu par le plafond de sécurité). Jean-Marc parti se coucher avec la suite actée pour cette nuit, sans supervision : une fois le BFH du 56 terminé, extraction + BFH enchaînés pour 44, 49, 85, 17, 40, 64 et 65, chaque étape protégée par le même `ulimit`. Mise à jour du dépôt remise au lendemain par Jean-Marc lui-même, comme toujours.
`<code>` n'avance pas dans cette section (aucune ligne du dépôt applicatif ASPIC touchée — tout le travail vit dans `src/scripts/` et la base `ENTREPRISES`) ; `<conception>` avance de 25 à 26 — nouvelle version **V1.23.26**.
## 27. Statistiques par commune, premières esquisses de carte de prospection géographique, et un correctif de fond sur le repli NAF (13 septembre 2026)
**Suite de la nuit du 12 au 13.** La chaîne sans supervision (44, 49, 85, 17, 40, 64, 65) s'est terminée sans incident, `ulimit -v` et garde-fou de pagination tenant leurs promesses. Une seconde chaîne a été lancée dans la foulée pour 31, 32, 33, 46, 47, 09, 13 et 66 (Occitanie/PACA), avec un décalage de départ pour ne jamais faire tourner deux extractions Sirene en même temps.
**Nouveau champ `ENT_CodeCommune` sur Phase1**, backfillé sans nouvel appel API (déjà présent dans le JSON brut stocké de chaque entreprise) sur les 7 départements de la première chaîne — nécessaire pour la fonctionnalité suivante.
**Statistiques par taille d'entreprise, par commune.** À la demande de Jean-Marc : cinq nouveaux champs sur `ENT_Communes` (`COM_EI`, `COM_TPE`, `COM_PME`, `COM_ETI`, `COM_GE`), alimentés par un script quotidien dédié (`ent_communes_stats.py`), pour que le futur module de recherche géographique n'ait jamais à compter en temps réel sur des tables Phase1 qui grossissent. Règle de classification : `ENT_CategorieEntreprise` prioritaire quand l'INSEE la renseigne (GE/ETI/PME) ; sinon répartition EI/TPE selon le premier chiffre de `ENT_NatureJuridique` (entrepreneur individuel = '1'). Deux bugs trouvés et corrigés avant mise en production : `SUM()` renvoie `NULL` (pas 0) sur un ensemble vide, faussant l'agrégat d'une commune sans aucune entreprise d'une catégorie — corrigé par `COALESCE(..., 0)` ; et surtout, une commune pouvait voir ses compteurs écrasés par un département qui ne la possède pas réellement (établissement secondaire présent dans la table Phase1 d'un département voisin) — corrigé en restreignant le calcul aux communes réellement rattachées au département traité via une jointure sur `ENT_Communes`, révélant au passage un conflit de collation (`utf8mb4_unicode_ci` vs `utf8mb4_0900_ai_ci`) entre tables créées à des moments différents, réglé par un `COLLATE` explicite dans la jointure.
**Première brique d'écran : la carte de prospection par géographie personnelle**, idée actée en section 25 (l'isochrone IGN), construite ce jour sous forme de prototypes interactifs (Claude Artifacts) plutôt que dans l'application : une commune de départ au centre, les communes accessibles en 30 minutes de route autour (isochrone réel + position géographique relative, projection locale corrigée du cosinus de la latitude), chacune avec son temps de trajet réel (API IGN `itineraire`) et ses effectifs EI/TPE/PME/ETI/GE réels tirés de `ENT_Communes`. Trois itérations avec retour de Jean-Marc avant d'aboutir à une version jugée satisfaisante : espacement des cartes pour éviter le chevauchement (algorithme de répulsion par paires, position géographique réelle conservée via un point + ligne de rappel quand une carte est déplacée pour la lisibilité), puis remplacement de la navigation par ascenseur natif (peu fiable dans le rendu de la fenêtre d'Artifact à certains niveaux de zoom navigateur) par un cliquer-glisser JavaScript, au clic gauche **et** au clic droit — Jean-Marc ayant signalé une habitude de navigation au clic droit. Quatre versions produites : Lorient (démonstration initiale), Lanester (ville de résidence de Jean-Marc, test de cohérence des distances), puis Concarneau et Beuzec-Cap-Sizun (Finistère) à la demande de Jean-Marc pour deux amis, comme simple partage d'un lien — pas encore une fonctionnalité ASPIC, mais une preuve que la donnée collectée a une valeur de démonstration immédiate.
**Correctif de fond sur le repli NAF à 3 niveaux — la vraie panne de la journée.** Le 3e niveau de repli (par code NAF précis), ajouté le 12 septembre pour débloquer Rennes (section 26 — un seul cas rencontré alors, section L), s'est révélé mal conçu à l'échelle : au lieu de ne tester que les codes NAF appartenant à la section en cours, il retestait les 732 codes en entier **à chaque section qui restait plafonnée**. Sur une grande ville où plusieurs sections dépassent le plafond simultanément (Toulouse : au moins 6 sections concernées), ce défaut a bloqué l'extraction du département 31 pendant plusieurs heures sans jamais planter ni progresser de façon visible — seule la répétition suspecte des mêmes codes (68.20A/68.20B, immobilier) à chaque section a permis de repérer le problème. Corrigé par une table de correspondance division→section NAF (standard, 21 sections, vérifiée exhaustive sur les 732 codes), limitant désormais le 3e niveau aux seuls codes réellement concernés par la section (6 codes pour la section L, contre 732 avant). Département 31 relancé depuis le début après correctif (pas de mécanisme de reprise par commune dans le script) : Toulouse traitée en quelques minutes au second passage, chaîne 2 poursuivie normalement.
**Cap symbolique franchi en cours de journée : plus de 2 millions d'entreprises cumulées** (2 227 587 exactement, sur 15 départements traités, le 33 encore en cours) — 31 (Haute-Garonne, avec Toulouse) est à lui seul le département le plus lourd traité jusqu'ici (312 359 entreprises).
**Premières réflexions sur la refonte du module Contacts, notées mais pas construites.** Jean-Marc a acté un changement de sens par rapport à la conception initiale des contacts : le lien entreprise↔contact ne sera plus exclu multiple (une entreprise peut avoir plusieurs contacts, un contact peut être lié à plusieurs entreprises), et la qualification (statut, score, canal) portera sur l'**entreprise**, pas sur le contact — la fiche contact ne conservant de l'entreprise que ce qui sert à garder le lien. Trois étapes de prospection distinguées : qualification des entreprises sélectionnées, recherche du ou des contacts, démarche commerciale classique — avec la précision que toute prospection ne partira pas nécessairement d'une qualification d'entreprise (d'autres approches resteront possibles). En préparant les écrans, Jean-Marc a demandé le détail du contenu du JSON brut Sirene stocké (structure décrite : `siege`, `dirigeants`, `finances`, `complements`) et relevé l'absence du capital social, confirmée après vérification (absent de cette API, disponible seulement côté INPI RNE, jamais interrogé à ce jour) — Jean-Marc a d'ores et déjà indiqué que ce champ relèverait de la qualification locale (donc jamais mutualisée), cohérent avec la séparation Phase1/Phase2 actée section 25. Sujet remis à plus tard dans son ensemble, quand ce chantier sera formellement ouvert.
**Statut.** 15 départements avec extraction et historique financier complets (17, 22, 29, 35, 40, 44, 48, 49, 56, 64, 65, 85 + 31, 32 achevés dans la chaîne 2, 33 en cours). Restent 46, 47, 09, 13, 66 en extraction puis BFH pour l'ensemble des 8 nouveaux départements. `ent_communes_stats.py` opérationnel mais pas encore automatisé quotidiennement (à brancher sur n8n avec la synchronisation incrémentale, toujours non construite). Quatre prototypes de carte de prospection publiés en Artifacts, partagés par Jean-Marc à deux amis — aucune intégration à l'application elle-même.
`<code>` n'avance pas dans cette section (tout le travail vit dans `src/scripts/`, la base `ENTREPRISES`, et des prototypes hors dépôt) ; `<conception>` avance de 26 à 27 — nouvelle version **V1.23.27**.
## 28. Suspension du BFH, découverte des arrondissements PLM, extraction France entière et refonte de la feuille de route ENTREPRISES (14-15 septembre 2026)
**Deux bugs de fond supplémentaires trouvés sur le BFH, le lendemain du précédent.** Le curseur de pagination de `bilans-saisis` utilisait le SIREN du dernier résultat comme `searchAfter` — or un SIREN a souvent plusieurs bilans (plusieurs années), donc revient plusieurs fois dans les résultats ; le garde-fou anti-boucle de la veille prenait ça à tort pour un blocage et abandonnait le lot en cours, perdant des bilans déjà partiellement récupérés (~14% des lots du département 31 touchés). Vérifié empiriquement que le champ `id` (identifiant unique par bilan, présent dans chaque réponse) donne une pagination propre sans doublon — corrigé. Puis, une fois relancé, l'API INPI a commencé à renvoyer des 401 (jeton expiré en cours d'exécution) après plusieurs heures d'appels continus — le script retentait 8 fois avec le même jeton mort, ce qui ne pouvait jamais réussir. Corrigé par une reconnexion automatique (`auth` devient un dict mutable partagé, se régénère tout seul sur un 401).
**Puis un vrai mur, cette fois non contournable par du code : un quota INPI dépassé** (`"errorCode":"quota_exceeded"`, `"webserviceCode":"QUOTA_SERVICE"`, confirmé par inspection directe du corps de réponse HTTP 429). Contrairement à une limitation de débit passagère, retenter ne sert à rien tant que le quota ne s'est pas régénéré (vraisemblablement journalier). Correctif appliqué par prudence (une `QuotaExceededError` dédiée fait interrompre immédiatement tout le traitement plutôt que de s'acharner lot après lot), mais **décision de Jean-Marc, plus radicale et plus sage : suspendre entièrement le BFH pour l'instant**, risque de bannissement du compte INPI jugé disproportionné par rapport au bénéfice — l'historique financier pourra être rattrapé "petit à petit dans les mois à venir", l'essentiel étant d'abord de disposer de l'identité de toutes les entreprises françaises pour commencer à travailler sur la structure des données et les écrans.
**Découverte majeure et indépendante, sur l'extraction Sirene elle-même : Paris, Lyon et Marseille (PLM) étaient massivement sous-captées.** Repéré à l'œil par Jean-Marc, qui a trouvé étonnant que le département 33 (Gironde, 416 980 entreprises) dépasse le 13 (Bouches-du-Rhône, alors 330 238) alors que Marseille est économiquement plus lourde que Bordeaux. Vérifié : dans les données Sirene, une entreprise marseillaise n'est quasiment jamais rattachée au code commune englobant (13055, ~10 résultats seulement) mais à son arrondissement (13201 à 13216) — or la liste des communes utilisée pour l'extraction ne renvoyait que le code englobant, jamais les 16 arrondissements, chacun pourtant déjà plafonné ou proche du plafond de 10 000 à lui seul. Corrigé (`sirene_extract.py` détecte désormais les trois villes à arrondissements et interroge `geo.api.gouv.fr?type=arrondissement-municipal` à la place du code englobant), `ENT_Communes` corrigée à la main pour le 13 (13055 supprimée, ses 16 arrondissements ajoutés), réextraction du 13 replanifiée en fin de chaîne. Paris et Lyon n'avaient pas encore été traités à ce stade, donc le correctif s'applique nativement dès leur premier passage.
**Décision de méthode pour la suite : terminer l'aspiration avec la méthode Sirene actuelle jusqu'au bout, sans la mélanger à une autre source en cours de route** (pour ne pas risquer d'hétérogénéiser les données), **mais reconcevoir entièrement la stratégie de mise à jour a posteriori**. Chaîne dédiée relancée (extraction seule, aucun appel INPI) pour l'intégralité de la France restante — 81 départements, métropole **et DOM-TOM inclus** (Jean-Marc : "Ne te limite pas à la métropole ! J'ai des amis à la Réunion, j'y passerai bien volontiers du temps !"), La Réunion placée en tête de liste pour cette raison personnelle. Rythme observé : environ 1h/département pour un département rural, plusieurs heures pour une grande ville nécessitant le repli NAF (Nice : 3h05). **Statut à la rédaction : 55 départements extraits, 7 036 958 entreprises** (~83% de l'estimation nationale de 8,5M) — la trajectoire confirme l'hypothèse de Jean-Marc que les 24 premiers départements traités (Bretagne puis Occitanie/PACA) étaient disproportionnellement denses, et que la moyenne va continuer de se rapprocher du national à mesure que les départements ruraux restants sont traités.
**Vérification croisée de plusieurs ratios avec Jean-Marc, à chaque fois confirmée par de vraies mesures plutôt qu'une simple extrapolation théorique** : le nombre d'entreprises (dépassement des 50% national constaté en direct) ; l'espace disque (10,69 Go réels pour 3,63M entreprises à ce moment, soit ~3 133 octets/entreprise compressé, projection nationale ~26,6 Go — cohérente avec l'estimation théorique de la section 26) ; et la proportion d'entrepreneurs individuels, mesurée sur les données déjà collectées plutôt que supposée : **41,3% d'EI (sans capital social), 58,7% de non-EI**, dont 3,27M déjà catégorisées GE/ETI/PME par l'INSEE.
**Piste ouverte par un ami de Jean-Marc, consultant senior, vérifiée et confirmée réelle : l'INPI met à disposition un export en masse du RNE, en FTP/SFTP, séparé de l'API paginée.** Recherche effectuée (dont un projet open-source `etalab/rncs_worker_api_entreprise` en confirmant l'usage réel) : structure stock + flux quotidien, format CSV, capital social explicitement présent comme champ. Demande d'accès faite par Jean-Marc le 15 septembre, accordée le jour même. Connexion testée (lecture seule) : `ftp://www.inpi.net` (FTP simple, pas du vrai SFTP malgré l'intitulé de la demande — source probable de la première tentative infructueuse de Jean-Marc sous FileZilla, configuré en SFTP). Quatre fichiers trouvés à la racine, deux jeux (formalités/comptes annuels) × deux versions chacun, aucun flux quotidien visible à ce niveau : `stock_RNE_formalites_NIVEAU1_20260320_1400.zip` (15,1 Go, mars 2026 — vraisemblablement la source du capital social), `stock_RNE_comptes_annuels_NIVEAU1_20260320_1400.zip` (4,3 Go). Dernier stock à 6 mois d'écart de la date du jour — jugé acceptable pour le capital social (donnée peu volatile) mais insuffisant en l'état pour les comptes annuels. Identifiants stockés dans `.env` (`INPI_RNE_FTP_*`), rien téléchargé, sujet à approfondir sans urgence.
**Réouverture et correction consciente d'une décision antérieure sur le capital social.** Jean-Marc était revenu sur ce qu'il avait dit trois jours plus tôt (section 27) : le capital social avait été classé à tort comme donnée de qualification privée par compte, alors que c'est une donnée factuelle de l'entreprise au même titre que le NAF ou l'effectif — elle doit vivre dans `ENTREPRISES`, mutualisée. Jean-Marc a également tranché explicitement pour une exception assumée au principe "jamais d'écriture dans `ENTREPRISES` déclenchée par un utilisateur" : la mise à jour du capital social pourra être initiée par une action utilisateur, précisément parce que c'est une information structurante — la conception du mécanisme (file d'attente, appel synchrone, etc.) reste ouverte, dépendante de limites de l'API RNE non encore connues (débit, temps de réponse), sans urgence vu le lancement commercial d'ASPIC visé "seulement" début 2028.
**Feuille de route formelle déposée par Jean-Marc** (`src/fdr/1. ENTREPRISES V1.0`), première utilisation de ce nouveau circuit de transmission (dépôt de fichier dans un répertoire dédié plutôt que description en conversation). Relecture complète effectuée à la demande explicite de Jean-Marc ("pour avoir ton retour... pas pour passer à l'action"). Points de retour marquants : découverte, en vérifiant une question de Jean-Marc sur les champs JSON, que `matching_etablissements[]` (jamais exploité) contient probablement l'adresse propre de chaque établissement secondaire ayant fait matcher une recherche — invalidant l'hypothèse de départ de Jean-Marc qu'on ne pourrait pas la connaître, et permettant potentiellement de retrouver un établissement secondaire par ville de recherche ; ~30% des entreprises ont plusieurs dirigeants dans le JSON, un seul capturé actuellement ; signalement d'une collision de nommage évitée de justesse (la feuille de route proposait de recycler le nom "Phase2", déjà porteur d'un sens différent et déjà documenté — Jean-Marc a tranché : `Phase2` disparaît purement et simplement de `ENTREPRISES`, son rôle originel renaît dans la base de chaque utilisateur sous un nom encore provisoire, `ASP_ENTQualif`).
**Clarification du trombinoscope des collaborateurs ASPIC, à l'occasion d'une coquille apparente.** La feuille de route mentionnait "la voix de Viktor" pour une future compétence de qualification — pas une erreur : les cinq collaborateurs nommés d'ASPIC sont **Matilda, Viktor, Sophia, Léon et Nikita**, et Jean-Marc a confirmé une répartition claire pour au moins deux d'entre eux : **Léon** porte la gestion (dont la garde d'`ENTREPRISES`), **Viktor** porte le commercial (dont la future qualification et son suivi temps/coût/réussite). Viktor n'a pas encore d'interface vocale prévue, "mais pourquoi pas quand je n'aurai plus que ça à faire".
**Statut.** BFH suspendu partout, à reprendre progressivement et sans urgence dans les mois à venir, avec une stratégie de mise à jour entièrement à reconcevoir (API paginée vs flux RNE en masse). Extraction Sirene seule en cours sans interruption depuis le 14 septembre, 55 départements sur 101 (métropole + DOM-TOM), 7 036 958 entreprises. Feuille de route ENTREPRISES V1.0 relue et commentée, pas encore amendée par Jean-Marc (en cours). Accès FTP RNE obtenu et testé en lecture seule, non exploité.
`<code>` n'avance pas dans cette section (deux scripts modifiés — `inpi_bilans_backfill.py`, `sirene_extract.py` — mais toujours hors du dépôt applicatif ASPIC lui-même) ; `<conception>` avance de 27 à 28 — nouvelle version **V1.23.28**.
## 29. Recalibrage à 13,4M d'entreprises, doublons de filiales confirmés, et institutionnalisation des feuilles de route (16 septembre 2026)
**Nouvelle méthode de travail actée pour cadrer les gros chantiers : les feuilles de route numérotées.** Jean-Marc, fatigué par la "navigation à vue" (peur constante d'oublier un point déjà discuté), a mis en place un circuit formel : il rédige une feuille de route par grand chantier, la dépose en PDF (puis `.docx`) dans un nouveau répertoire `src/fdr`, numérotée `1` à `11` selon le chantier (1. Évolutions BDD ENTREPRISES, 2. Gestion des contacts, 3. Sélection des Entreprises, 4 à 11 pas encore communiqués). Claude Code la lit intégralement, donne un retour écrit — jamais d'implémentation à ce stade — et, règle rendue systématique en cours de journée, **intègre désormais toujours ses commentaires dans une nouvelle version du fichier** plutôt que de les laisser vivre uniquement dans la conversation ("je pourrais décider de supprimer, mais je ne veux pas risquer de perdre un commentaire"). Outillage installé pour l'occasion sur le VPS (`python3 -m pip install --break-system-packages python-docx fpdf2 openpyxl` — pip lui-même absent, installé via `apt-get install python3-pip`) : génération de `.docx` éditables (Jean-Marc a explicitement demandé l'arrêt de la génération PDF, "je ne peux rien en faire"), lecture de maquettes d'écran Excel (`openpyxl`, testé avec succès sur une première maquette de fiche contact). Deux bugs de génération trouvés et corrigés en cours de route, tous deux désormais documentés pour ne pas être réintroduits : accents français supprimés par réflexe d'un ancien évitement d'encodage devenu inutile, et titre du document supprimé par erreur du `.docx` en corrigeant un doublon qui n'existait que côté PDF (les deux générateurs ne partagent pas la même logique d'en-tête).
**Feuille de route "Évolutions BDD ENTREPRISES" (ex-ENTREPRISES) itérée de V1 à V6.** Relecture point par point à chaque version, jamais d'implémentation. Découverte notable en répondant à une question de Jean-Marc sur les champs JSON : `matching_etablissements[]` contient un booléen `est_siege` directement exploitable pour distinguer siège et établissement secondaire, sans déduction ni colonne enum à maintenir — et ce même tableau donne probablement l'adresse propre de chaque établissement secondaire, invalidant l'hypothèse initiale de Jean-Marc qu'on ne pourrait pas la connaître. **Vérification empirique déterminante, demandée explicitement par Jean-Marc pour trancher un doute méthodologique** (il voulait s'assurer que la preuve ne reposait pas sur le champ adresse, déjà connu comme erroné pour les filiales) : comparaison réelle entre `Entreprises35Phase1` et `Entreprises44Phase1`, confirmant **7 802 SIREN présents comme ligne physique dans les deux tables à la fois** (exemple vérifié : "G2 (THE BRITISH CASHMERE)") — preuve reposant sur l'existence même de la ligne dans chaque table indépendante, pas sur son contenu. Conclusion actée par Jean-Marc : le stockage des filiales devra changer en profondeur pour éliminer ce doublon structurel, sujet noté pour la prochaine feuille de route plutôt que traité dans l'urgence. Autres décisions actées au fil des versions : `Phase2` disparaît purement et simplement de `ENTREPRISES` (son rôle renaît dans la base de chaque compte utilisateur, nom pressenti `ASP_ENTQualif`) ; le JSON brut migre vers une nouvelle table dédiée (`Phase4`, jamais "Phase2" pour éviter la collision de sens) ; ~30% des entreprises ont plusieurs dirigeants dans le JSON (un seul capturé actuellement) mais ces dirigeants sont rattachés à l'entreprise entière, pas à un établissement précis — limite à connaître avant de concevoir la future table des dirigeants.
**Nouvelle feuille de route "3. Sélection des Entreprises" déposée et itérée (V1 à V5).** Pose les bases du futur module de sélection multicritère (géographie, secteur, effectif, date de création...), explicitement bloqué tant que la structure d'`ENTREPRISES` n'est pas définitive. Points de retour marquants : le mécanisme "critères extensibles sans modifier le code" que souhaite Jean-Marc n'est pas à inventer, c'est le même patron déjà construit pour ASPIC (règles et consignes en langage naturel, section 15/17) ; l'hypothèse de Claude sur les dépendances entre critères (uniquement au sein de la hiérarchie géographique, pas entre facettes indépendantes) confirmée indépendamment par Jean-Marc, avec la nuance qu'il faudra vérifier à l'usage pour les EI/associations ; question ouverte sur le stockage des quotas anti-abus (MIRADOR, dont le rôle actuel se limiterait à l'étendre, vs la base propre de chaque compte).
**Recalibrage majeur de la cible nationale : 8,5M devient 13,4M.** Jean-Marc a trouvé un site tiers (nouvelles-entreprises.com, republication commerciale de données SIRENE) annonçant 13 458 259 "entreprises actives au registre SIRENE (établissements sièges)", recalculé chaque nuit. Écart expliqué et non contradictoire avec le chiffre de 8,5M utilisé jusque-là : l'INSEE publie aussi un agrégat statistique "entreprises" qui **regroupe les filiales d'un même groupe en une seule entité** — notre extraction, elle, dédoublonne par SIREN (unité légale), pas par ce regroupement statistique, donc sa cible réelle de convergence est bien plus proche de 13,4M. Projection disque recalculée sur mesure réelle (28,14 Go pour 8,7M entreprises à ce moment, soit 3 473 octets/entreprise compressé) : **~43,5 Go pour 13,4M**, toujours très confortable (~22-23% du disque du VPS). Vérification annexe : Bitdefender a signalé une activité suspecte sur ce même site tiers (`nouvelles-entreprises.com`) pendant que Jean-Marc le consultait — analysé et relativisé (le chemin `_nuxt/builds/latest.json` est un fichier technique standard et inoffensif du framework Nuxt.js, l'alerte provient plus probablement du `http://` non chiffré de l'URL) ; onglet fermé par précaution, sans lien avec ASPIC.
**Extraction Sirene poursuivie sans interruption.** Lyon (69) traité avec succès grâce au correctif arrondissements de la section 28 : **506 425 entreprises**, le plus gros département à ce jour, confirmant en conditions réelles que le correctif tient la charge (ex. Lyon 1er arrondissement seul : 17 781 entreprises, très supérieur à ce que le code englobant aurait donné). **Statut à la rédaction : 74 départements sur 101, 9 855 993 entreprises** (~74% de la nouvelle cible à 13,4M) — Paris et le reste de l'Île-de-France restent à traiter, plus la réextraction du 13 (Marseille) en toute fin de chaîne.
**Clarifications de fond, hors chantier ENTREPRISES, à la demande de Jean-Marc.** Distinction actée entre l'abonnement Claude Max personnel de Jean-Marc (couvre son usage interactif, pas l'API) et l'API Anthropic que consommeront les utilisateurs d'ASPIC (compte développeur séparé, facturation à l'usage) — point de confusion corrigé avant qu'il n'affecte un choix commercial. Piste "clé API par utilisateur" explorée puis écartée par Jean-Marc lui-même (confusion avec une autre idée) au profit de **Claude Cowork côté utilisateur final pour les fonctionnalités réseaux sociaux** — logique validée : Cowork tourne sur l'abonnement personnel de l'utilisateur, donc hors du budget API d'ASPIC, cohérent avec le souci de marge de Jean-Marc. Jean-Marc a lui-même noté que son abandon passé de Cowork (écriture bloquée hors `C:\`, dépôt Git en lecture seule) ne s'applique pas au même besoin : ce qu'il faisait avec Cowork était de l'administration serveur exigeant un accès direct (d'où son usage actuel de Claude Code), alors que le besoin envisagé pour les utilisateurs ASPIC est bureautique/local.
**Statut.** Extraction Sirene : 74/101 départements, 9 855 993 entreprises, en cours sans incident. BFH toujours suspendu (section 28). Deux feuilles de route actives dans `src/fdr` : "1. Évolutions BDD ENTREPRISES" (V6, renommée par Jean-Marc) et "3. Sélection des Entreprises" (V5) — aucune des deux encore validée pour passage à l'implémentation. Accès FTP RNE toujours inexploité. Collaborateurs ASPIC (Matilda, Viktor, Sophia, Léon, Nikita) et leurs rôles connus consignés en mémoire pour la suite.
`<code>` n'avance pas dans cette section (feuilles de route et scripts d'outillage PDF/docx/xlsx vivent hors du dépôt applicatif ASPIC) ; `<conception>` avance de 28 à 29 — nouvelle version **V1.23.29**.
## 30. Incident de parallélisme sur l'extraction, naissance du projet entreprises.bzh, et mesure réelle du double-comptage (17 septembre 2026)
**Nouvel incident, cette fois provoqué par Claude Code lui-même.** En tentant de rattraper le département 74 (interrompu la veille par un aléa réseau), un script de reprise a été lancé **en parallèle** de la chaîne principale — erreur de jugement, l'hypothèse que `recherche-entreprises.api.gouv.fr` n'avait jamais montré de limite s'est révélée fausse sous charge concurrente. Les deux traitements simultanés ont fini par saturer l'API (HTTP 429 soutenu), faisant planter en cascade **75 (Paris), 76 et 77** en plus du 74 déjà en cours de reprise. Aucune perte de données (conception UPSERT), mais quatre départements incomplets. Corrigé sur deux plans : `http_get_json()` dans `sirene_extract.py` durci (retries avec backoff exponentiel sur 429 et respect de `Retry-After`, rattrapage de `http.client.HTTPException`/`ConnectionError` en plus de `URLError` — un `RemoteDisconnected` n'était pas rattrapé avant, cause du tout premier plantage du 74) ; et surtout, **plus aucun traitement en parallèle désormais, toujours strictement séquentiel**, leçon actée en mémoire. Une reprise séquentielle de 74/75/76/77 a été programmée pour démarrer automatiquement une fois la chaîne principale entièrement terminée, sans chevauchement possible. Un second incident, sans lien, a suivi le 17 au soir : le département 88 a échoué sur un verrou MySQL (`Lock wait timeout`), probablement lié à la charge d'un calcul de dédoublonnage lancé en parallèle par Claude Code — ajouté à la liste des départements à reprendre.
**Naissance d'un projet dérivé : `entreprises.bzh`.** Une observation de Jean-Marc sur le modèle économique de nouvelles-entreprises.com (revenu réel sur l'envoi de courrier postal, pas l'abonnement) a fait germer l'idée d'un site indépendant, construit sur la même base `ENTREPRISES`, proposant l'accès aux données d'entreprises via une méthode de recherche originale (isochrone/temps de trajet réel, pas un simple rayon en km — confusion de vocabulaire de Jean-Marc corrigée en cours de route). Jean-Marc a demandé à une autre session Claude un dossier d'analyse de marché complet (tarifs, structure de facturation, contraintes légales) — document de grande qualité, sourcé et prudent sur ses propres limites, déposé et relu en détail. Trouvailles marquantes : **aucun concurrent recensé ne combine fraîcheur quotidienne et sélection géographique fine** (Smappen s'en approche mais avec une base trimestrielle) — le créneau existe réellement. Structure de facturation suggérée : pas de prix à la ligne (course au prix bas déjà occupée), mais deux prestations distinctes, extraction brute alignée marché et "étude de zone" au forfait, seule cette dernière valorisant réellement l'outil. Point juridique découvert en cours de lecture, avec impact technique direct : depuis mars 2023, le statut "non-diffusible" est remplacé par un statut **"P" (diffusion partielle)**, à exclure de toute redistribution commerciale — or `statut_diffusion` n'est capturé nulle part comme colonne exploitable dans `ENTREPRISES` aujourd'hui, seulement noyé dans le JSON brut. Décision actée : ne pas modifier l'extraction en cours (au-delà de 80% de la France) pour ce champ, le rattacher au grand remodelage de Phase1 déjà prévu.
**Objectif du projet précisé par Jean-Marc, plus riche qu'une simple opportunité commerciale.** Visée : mise en ligne à la création de la société (**début 2027**, soit environ un an avant le lancement commercial d'ASPIC), avec cinq finalités explicites — avoir quelque chose en vente dès le début d'activité, s'affronter concrètement à une solution de paiement en ligne, à la facturation électronique, se pousser à communiquer sur les réseaux sociaux via les fonctionnalités mêmes d'ASPIC, et dégager un peu de trésorerie pour financer un futur serveur OVH dédié (non mutualisé). Coût d'entrée dérisoire : le seul nom de domaine, `entreprises.bzh`, acquis dans la foulée. Jean-Marc reste "à l'affût comme un chien de chasse" si un vrai modèle économique se dessinait, sans en faire une condition. Le module de sélection sera construit une fois pour ce site puis répliqué dans ASPIC — et Jean-Marc envisage déjà l'argument marketing du jour où ASPIC sortira : ces fonctionnalités y seront incluses gratuitement.
**Discussion approfondie sur l'enrichissement de contact via LinkedIn, questionnement personnel de Jean-Marc sur l'exposition légale.** Un partenaire commercial de Jean-Marc a vanté l'usage quotidien de Waalaxy sans jamais avoir été banni — argument que Jean-Marc a lui-même écarté par une analogie efficace (rouler à 100 km/h sur une route de campagne sans jamais croiser de gendarme n'en fait pas une pratique sûre). Question soulevée par Jean-Marc : LinkedIn tolérerait-il tacitement ce type d'usage dans une mesure raisonnable ? Réponse nuancée apportée : LinkedIn a un historique documenté de poursuites actives contre des **éditeurs** d'outils d'automatisation (affaire hiQ Labs, mises en demeure), même si la détection au niveau utilisateur individuel reste probabiliste — donc la distinction pertinente pour Jean-Marc n'est pas "combien de temps avant d'être banni" mais "éditeur vs utilisateur isolé", un éditeur étant une cible bien plus exposée. Piste connexe soulevée par Jean-Marc et laissée ouverte, explicitement qualifiée de "borderline" par lui-même : faire réaliser la qualification via Claude Cowork en local chez l'utilisateur plutôt qu'un scraping centralisé — analysé comme une **dilution** du risque (compte individuel, détection plus difficile à grande échelle) mais pas une **élimination** de sa nature (toujours hors CGU si l'extraction reste automatisée), avec la même question de responsabilité d'éditeur si la fonctionnalité était packagée comme telle.
**Mesure réelle du double-comptage inter-départements, chiffrée pour la première fois.** Calcul `COUNT(DISTINCT SIREN)` sur l'ensemble des tables Phase1 existantes : **12 331 573 lignes brutes, 11 142 436 SIREN réellement distincts, soit 1 189 137 doublons (9,6%)** — cohérent avec la découverte de la section 29 (35 vs 44). Poids disque correspondant : ~4,18 Go sur les 43,33 Go actuels de Phase1, présenté comme plafond théorique (le futur modèle allégera ces lignes plutôt que de les supprimer, l'info des établissements secondaires restant voulue). Conséquence rassurante sur la question "va-t-on dépasser 13,4M ?" : le total réellement distinct progresse normalement, la marge restante (Île-de-France complète, Marseille, Corse, DOM-TOM) ajoutera du vrai volume, pas seulement du doublon.
**Feuilles de route ENTREPRISES et Sélection des Entreprises, nouvelles versions.** "1. Évolutions BDD ENTREPRISES" passée en V7 (le point statut "P" y a été ajouté). "3. Sélection des Entreprises" largement étoffée par Jean-Marc (V6) : le projet devient l'épine dorsale d'`entreprises.bzh`, méthodes de recherche listées, trois premières briques d'écran détaillées (avertissement RGPD, localisation géographique, type d'entreprise) accompagnées de leurs maquettes Excel. Relecture complète, correction actée (rayon en km → temps de trajet), confirmation de la sélection géographique limitée à une valeur unique par niveau (région/département/ville), passée en V7 avec les retours intégrés. Deux bugs supplémentaires de génération `.docx` trouvés et corrigés : langue par défaut du document (anglais au lieu de français, `w:lang` absent du style Normal) et titre manquant sur "Sélection des Entreprises" (même bug que la section 29 sur ENTREPRISES, pas encore appliqué à ce fichier-là).
**Statut.** Extraction Sirene : reste l'Île-de-France (91 à 95), la Corse, les DOM-TOM restants (971/972/973/976) et le 13 (Marseille) dans la chaîne principale, puis une reprise séquentielle de 74/75/76/77/88. Fin réaliste estimée pour le week-end du 19-20 septembre, largement avant l'indisponibilité de Jean-Marc le lundi 21 — reprise du chantier de restructuration ENTREPRISES prévue mardi ou mercredi. BFH toujours suspendu (section 28). Accès FTP RNE toujours inexploité. Projet `entreprises.bzh` acté, domaine acquis, encore au stade de réflexion sur les écrans.
`<code>` n'avance pas dans cette section (deux scripts modifiés — `sirene_extract.py` durci — et outillage de feuilles de route, toujours hors du dépôt applicatif ASPIC) ; `<conception>` avance de 29 à 30 — nouvelle version **V1.23.30**.
## 31. Alerte disque serveur — 79 Go de binlogs MySQL jamais purgés (18 septembre 2026)
**Alerte remontée par Jean-Marc** : 160 Go occupés sur 193 Go (84%), sans lien apparent avec l'avancement de l'extraction. Diagnostic mené avec Claude Code : la base `ENTREPRISES` elle-même ne pèse que 52 Go, croissance normale et attendue vu le volume actuel (~13M lignes). Le vrai responsable : **79 Go de journaux binaires MySQL (binlogs) accumulés dans 76 fichiers sur l'instance `mysql2`**, jamais purgés. Cause identifiée, pas une erreur de configuration à proprement parler : la purge automatique était bien active (`binlog_expire_logs_auto_purge=ON`), mais réglée à 30 jours de rétention — un réglage raisonnable pour l'usage CRM habituel, disproportionné face au volume d'écriture exceptionnel généré par une semaine d'extraction intensive (le chantier ayant commencé il y a moins de 30 jours, rien n'avait encore pu être purgé automatiquement).
**Vérifié avant toute action** : aucune réplication ne dépend de ces binlogs (`SHOW REPLICAS` vide) — purge sans risque pour l'intégrité des données. Un premier essai (`PURGE BINARY LOGS`) a été bloqué par le classificateur de sécurité du mode auto de Claude Code, qui l'a signalé comme une possible altération de journaux d'audit — un principe de précaution générique s'appliquant même à un cas légitime. Plutôt que de chercher un contournement, Claude Code a exposé clairement la situation et les deux options à Jean-Marc (exécuter lui-même la commande, ou autoriser explicitement l'action) ; Jean-Marc a choisi d'autoriser, la commande a alors été acceptée et exécutée sans encombre.
**Résultat : 79 Go libérés, disque ramené de 84% à 43% d'occupation** (161 Go → 83 Go utilisés sur 193 Go). Rétention réduite temporairement à 3 jours (`binlog_expire_logs_seconds=259200`) le temps que dure l'extraction intensive, à remettre à 30 jours une fois celle-ci terminée si c'est le réglage voulu en régime de croisière.
**Suite envisagée par Jean-Marc** : intégrer une surveillance de l'espace disque au BackOffice d'ASPIC (déjà home des outils internes — voir section 32 mentionnée dans le journal Index pour le BackOffice existant) plutôt que de compter sur une vérification manuelle occasionnelle. Raison donnée : la console Hostinger elle-même n'affiche pas l'occupation disque en temps réel (données remontées avec un délai notable), d'où l'intérêt d'un indicateur suivi en direct côté ASPIC plutôt que de dépendre de ce tableau de bord tiers. Idée notée, pas encore développée.
`<code>` n'avance pas dans cette section (intervention d'infrastructure sur le serveur, hors dépôt applicatif ASPIC) ; `<conception>` avance de 30 à 31 — nouvelle version **V1.23.31**.
## 32. Bug Corse identifié et corrigé, deux départements orphelins retrouvés, et nom définitif d'entreprises.bzh trouvé — entreprizh.bzh (18-19 septembre 2026)
**Un troisième "monstre" découvert dans la dernière ligne droite de l'extraction : la Corse (2A/2B) revenait systématiquement à 0 entreprise, sans aucune erreur.** En creusant à la demande de Jean-Marc ("chez moi les tables sont vides"), diagnostic isolé et vérifié empiriquement par appels directs à l'API : le paramètre `code_commune` de `recherche-entreprises.api.gouv.fr` échoue silencieusement (0 résultat, HTTP 200, aucune erreur) dès qu'il reçoit un code commune alphanumérique (`2A004`, `2B033`...), alors qu'un code numérique classique (`01004`) fonctionne normalement — bug propre à l'API officielle, pas au script. La Corse est le seul territoire français dont les codes commune INSEE ne sont pas purement numériques, ce qui explique pourquoi ce cas n'était jamais apparu ailleurs. Le paramètre `departement` de la même API, lui, fonctionne correctement même pour `2A`/`2B`. Corrigé dans `sirene_extract.py` : nouvelle fonction générique `fetch_entreprises_for_filter()` (factorisée depuis l'ancienne `fetch_commune_entreprises()`, même cascade de repli section NAF → code NAF précis), et `fetch_departement_entreprises_direct()` qui interroge le département entier d'un coup pour tout code non purement numérique — `run_department()` bascule automatiquement sur ce mode via `dep.isdigit()`.
**Deux départements orphelins supplémentaires retrouvés en vérifiant l'état réel de `ENT_Departements` avant de répondre à une question de Jean-Marc sur le total nationale.** Le **88 (Vosges)**, planté sur le verrou MySQL déjà repéré le 17 septembre (section 30), n'avait en fait jamais été réintégré dans le script de reprise malgré l'intention notée à l'époque — 33 457 lignes partielles en base, aucune ligne dans `ENT_Departements`. Le **48 (Lozère)**, découverte inédite, avait 21 859 entreprises déjà correctement extraites mais aucune ligne finale écrite (interruption juste avant cette dernière étape, cause non identifiée, sans doute un arrêt de processus isolé antérieur) — n'était dans aucune file d'attente. File de reprise (`/tmp/redo_74_75_76_77.sh`) mise à jour et relancée avec la liste complète : **74, 75, 76, 77, 88, 2A, 2B, 48**, toujours strictement séquentielle derrière la fin de la chaîne principale (13/Marseille, dernier département de cette dernière).
**Total national fait le point à la demande de Jean-Marc, chiffré précisément.** 94 départements bien terminés et fiables : **13 088 450 entreprises**. Les 9 restants (13 en cours + les 8 de la file de reprise) totalisaient à cet instant ~1,51M de lignes déjà en base mais partielles pour la plupart — notamment 76 et 77, très en dessous de ce qu'ils devraient contenir. Confirmation chiffrée d'une intuition de Jean-Marc : Paris (75) affichait déjà 831 979 entreprises avant même la fin de sa reprise, proche du million pressenti.
**Exploration visuelle du logo (détecteur de métaux/pépite) refermée pour l'instant, sans aboutir.** Jean-Marc a itéré des dizaines de fois avec l'outil Pippit AI sur une variante ajoutant des traces de balayage au sol, jusqu'à ce que l'outil lui-même signale son incapacité à retoucher précisément (absence de calques, dérive cumulative après trop d'itérations). Une tentative de Claude Code de proposer une alternative vectorielle (SVG, deux versions publiées en Artifact) a été jugée trop abstraite/schématique par rapport au rendu quasi-photo recherché — écartée. Piste de sortie identifiée mais reportée par Jean-Marc à une prochaine session (fatigue du sujet ce soir-là) : retoucher manuellement l'image déjà quasi satisfaisante dans un éditeur à calques (Photopea suggéré, gratuit, en ligne) plutôt que de continuer à batailler avec la génération IA — le changement demandé (supprimer une ligne, limiter le nombre de traces) est une retouche localisée sur fond presque uni, largement à la portée d'un éditeur classique.
**Recherche du nom de domaine d'entreprises.bzh reprise et closes après plusieurs allers-retours, tous vérifiés avant achat conformément au principe explicite de Jean-Marc ("éviter les problèmes plutôt que devoir les gérer", répété ce soir-là).** `prospecter.bzh`, pourtant validé une première fois, écarté après vérification INPI : une marque française active **"PROSPECTER"** (FR4532717) existe, détenue par **TESSI**, déposée en classe 35 (publicité, gestion commerciale, télémarketing, acquisition de données) — recoupement direct avec l'activité visée, risque jugé trop sérieux malgré la nature partiellement descriptive du mot. `fichiers-prospects.fr`/`.com`, repérés disponibles par Jean-Marc, vérifiés propres (aucune marque, aucun site homonyme) mais la piste a été abandonnée en cours de route sans avoir été formellement invalidée, Jean-Marc étant parti explorer un concurrent adjacent (`easyfichiers.com`) puis, de fil en aiguille, l'API de Pappers. Vérification de l'API Pappers menée à la demande de Jean-Marc : modèle freemium (100 crédits gratuits à l'inscription puis facturation au crédit, abonnements à partir de 20€/mois) — mais surtout, ses CGU interdisent explicitement toute extraction/réutilisation de sa base de données et, clause spécifique, toute utilisation "pour alimenter, enrichir ou nourrir un service, produit, système ou solution tiers" : exactement l'usage qu'envisageait Jean-Marc pour les numéros de téléphone qu'il y avait repérés (donnée absente des sources SIRENE/INPI/BODACC officielles, donc nécessairement un enrichissement propriétaire de Pappers) — piste abandonnée sur ce constat, sans ambiguïté.
**Nom finalement retenu : `entreprizh.bzh`** (jeu de mots entreprise/Breizh), trouvé par Jean-Marc, disponible et déjà accordé par l'association gestionnaire du `.bzh` — contrairement à `entreprises.bzh` en son temps. Vérifié par Claude Code (aucune marque, aucun site homonyme, le plus proche étant `entreprendre.bzh` et `mon-entreprise.bzh`, tous deux trop différents pour prêter à confusion) puis confirmé directement par Jean-Marc lui-même sur data.inpi.fr : aucun résultat. Achat décidé le soir même.
**Statut.** Extraction Sirene : chaîne principale sur le 13 (Marseille, dernier département), file de reprise (74/75/76/77/88/2A/2B/48) prête à démarrer automatiquement à sa suite, code Corse corrigé et non encore testé en conditions réelles — premier point à vérifier à la prochaine reprise de session. Nom de domaine d'`entreprises.bzh` définitivement tranché (`entreprizh.bzh`), achat en cours par Jean-Marc. Logo toujours en réflexion, retouche manuelle envisagée mais pas commencée. Restructuration ENTREPRISES (siège/filiale, partitionnement, statut "P") toujours en attente de la fin complète de l'extraction, Jean-Marc envisageant d'ouvrir une session neuve à ce moment-là plutôt que de poursuivre celle-ci.
`<code>` avance : correctif du bug Corse dans `src/scripts/sirene_extract.py` (hors dépôt applicatif ASPIC comme les autres scripts d'extraction) ; `<conception>` avance de 31 à 32 — nouvelle version **V1.23.32**.
## 33. Extraction Sirene France entière terminée — 15 972 915 entreprises (19-20 septembre 2026)
**Fin complète de l'extraction, sans incident sur la dernière ligne droite.** La chaîne principale a bouclé le 13 (Marseille) dans la nuit du 18 au 19 septembre, puis la file de reprise a enchaîné automatiquement et sans supervision sur les huit départements restants : 74 (terminé dans la nuit), 75/Paris (le plus gros morceau, ~15h de traitement à lui seul vu son volume), 76, 77, 88, puis **2A et 2B — premier vrai test du correctif Corse de la section 32, réussi du premier coup** (55 190 et 54 823 entreprises, aucune anomalie), et enfin 48 pour clore la liste. Le recalcul des statistiques par commune (`ent_communes_stats.py`) a suivi automatiquement en bout de chaîne (34 916 communes mises à jour).
**Total national final : les 101 départements de France (métropole + DOM-TOM) sont extraits, pour 15 972 915 entreprises** — sensiblement au-dessus des projections successives de la conversation (8,5M initialement, recalibré à 13,4M section 29, puis à ~15-15,5M au fil de la reprise des derniers départements), l'écart provenant essentiellement des trois départements les plus lourds (Paris, Seine-Maritime, Seine-et-Marne) qui étaient restés très sous-captés depuis l'incident du 17 septembre jusqu'à leur réextraction complète. Disque à 60% d'occupation (115 Go/193 Go) une fois tout terminé, aucun processus d'extraction encore actif.
**Statut.** Extraction Sirene : **terminée, définitivement**. Plus aucun script en tâche de fond. Le chantier de restructuration `ENTREPRISES` (siège/filiale, partitionnement natif MySQL évoqué section 29, split de `Phase4`, capture du statut "P") peut désormais démarrer sur des données complètes — Jean-Marc prévoit d'ouvrir une session neuve pour ce chantier plutôt que de poursuivre celle-ci. Nom de domaine `entreprizh.bzh` (section 32) en cours d'achat. Rétention des binlogs MySQL toujours réduite à 3 jours (section 31) — à remettre à 30 jours maintenant que la période d'écriture intensive est terminée, point non encore traité.
`<code>` n'avance pas dans cette section (fin d'un traitement déjà en cours, aucune modification) ; `<conception>` avance de 32 à 33 — nouvelle version **V1.23.33**.
+83 -1
View File
@@ -53,8 +53,90 @@ CREATE TABLE `ASP_Agents` (
**État à la fin de cette session.** Fonctionnalité validée de bout en bout sur les quatre agents actifs (ouverture → contenu → clôture, y compris avec variantes de prononciation). Version applicative incrémentée une fois pour cette session, **V1.006**.
**Reste à faire.**
**Reste à faire (à la clôture de la session du 1-2 septembre).**
- Étape 2 : règles d'interprétation du contenu dicté (aujourd'hui stocké tel quel, sans traitement), envisagées via n8n, avec des règles modifiables par l'utilisateur.
- Continuer à observer les agents actifs en usage réel ; envisager d'autres retraits, ou l'ajout de nouvelles variantes via `AGN_Variantes`, si de nouvelles confusions apparaissent — sans jamais y ajouter un mot français courant.
- Vérifier concrètement que le `.htaccess` du dossier `/journal` bloque bien l'accès direct (`curl -I` doit répondre `403`, pas `200`) — point resté en suspens depuis le 31 août, sans lien avec le pilotage vocal mais toujours ouvert.
- Documentation utilisateur : pas encore mise à jour pour cette fonctionnalité, encore en phase d'essais techniques (choix des noms d'agents notamment) — à envisager une fois l'usage stabilisé.
## 3 septembre 2026 (soirée) : vers l'étape 2 — constitution d'une banque de dictées réelles avant toute règle d'interprétation (V1.010)
Reprise de session le même jour (voir aussi section 28, où le micro de l'écran de fond est branché sur la reconnaissance vocale réelle plus tôt dans cette même soirée). Discussion engagée par Jean-Marc après avoir montré les évolutions du pilotage vocal à sa compagne, qui a directement fait le lien avec son expérience de SAP dans une grosse PME (application qui rame, arrêts brutaux, procédures lourdes pour un simple changement — délai moyen de 2-3 jours via un prestataire externe pour créer un utilisateur ou corriger un bug de saisie). Jean-Marc en tire une conviction : l'aspect vocal sera vraisemblablement un atout concurrentiel important vis-à-vis de futurs clients, ce qui **augmente l'exigence de fiabilité** plutôt que de la relâcher — inquiétude explicite sur la capacité du projet à tenir cette fiabilité une fois le pilotage vocal étendu au-delà de la ToDoList (contacts, catalogue, devis et affaires en cours, facturation...), sans tomber dans un empilement de règles/rustines au cas par cas comme cela avait déjà été nécessaire pour Lilou/"les loups" (voir plus haut).
**Distinction actée avec Claude, développée en détail dans les réflexions stratégiques (section 12) :** deux problèmes différents, à ne pas traiter par le même mécanisme. D'un côté, la confusion de reconnaissance en amont (mot déclencheur mal transcrit) — un problème de reconnaissance audio, déjà traité par `AGN_Variantes` et le choix de noms d'agents qui passent bien à l'oral, dont la surface d'erreur ne grandit pas avec le nombre de modules d'ASPIC. De l'autre, l'interprétation d'une dictée déjà bien captée — transformer "il faut ajouter un contact, il s'appelle Pierre Chartier, il est plombier à Quimper..." en une action structurée (quel module, quelle opération, quels paramètres). Conclusion : ni RAG ni base vectorielle pour ce deuxième problème (outil de recherche floue dans un corpus, pas de déclenchement d'action) — un LLM à sortie structurée (function calling) contre un catalogue d'actions par module est l'outil adapté, sans avoir besoin de pgvector avant qu'un vrai besoin de recherche floue (catalogue produits, historique de devis) n'apparaisse. Modèle affiné avec Jean-Marc pour le retour visuel sur le post-it : deux statuts distincts plutôt qu'un seul niveau de confiance — "à corriger" (information manquante/incohérente, l'IA n'a pas su comprendre) versus "à valider" (l'IA a bien compris et préparé l'action, mais cette action précise exige toujours un accord humain avant exécution réelle, par nature — ex. l'envoi d'un devis à un client — indépendamment du niveau de confiance de l'interprétation). Deux signes graphiques distincts envisagés sur le post-it (un point d'interrogation pour "à corriger", proposé par Jean-Marc ; un symbole différent, à définir, pour "à valider") plutôt qu'un seul, pour que Jean-Marc sache d'un coup d'œil ce qu'on attend de lui sans ouvrir le post-it.
**Décision pour cette session : ne pas anticiper les règles, constituer d'abord un corpus réel.** Cohérent avec la leçon déjà tirée de Lilou/"les loups" — ce qui semble naturel en le pensant seul (ex. faire commencer chaque instruction par le prénom d'un agent) se révèle différent une fois dicté réellement et de façon détendue. Jean-Marc a d'ailleurs déjà relevé, avant même d'avoir l'outil, deux exemples concrets : il commence souvent une instruction par "il faut"/"il faudrait" plutôt que par le prénom d'un agent, et utilise aussi bien "puis" que "ensuite" pour marquer un enchaînement — points à garder tels quels dans le corpus plutôt qu'à corriger à la main, l'objectif de cette phase étant justement de laisser le vrai usage remonter ces variations plutôt que de les deviner.
**Outil construit : `micro_test.js` + `dictees_test.php` + table `ASP_DicteesTest`.** Outil de mise au point interne, explicitement pas destiné aux futurs utilisateurs (précision de Jean-Marc). Table créée par Jean-Marc lui-même (voir convention actée en tête de journal) :
```sql
CREATE TABLE `ASP_DicteesTest` (
`DCT_Incr` int NOT NULL,
`DCT_Theme` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'Gestion des Contacts',
`DCT_Texte` text NOT NULL,
`DCT_Date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`DCT_Utilisateur` varchar(3) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
Choix de conception actés avec Jean-Marc, plusieurs à contre-courant du réflexe initial de Claude :
- **Aucun mot déclencheur.** Le clic sur le micro fait déjà office de signal explicite de début — contrairement à `micro.js` en écoute ambiante continue, qui doit filtrer une conversation de fond d'une vraie commande, ce problème ne se pose pas ici : tout ce qui est dit après le clic est un essai volontaire. Ça répond directement à la remarque de Jean-Marc sur le prénom d'agent en tête de phrase — cette contrainte est un artefact du filtrage en écoute ambiante, pas une exigence de l'interprétation elle-même.
- **Pas de segmentation "à la main".** Le "silence de x secondes" évoqué par Jean-Marc n'est pas un réglage exposé par l'application : c'est le moteur de reconnaissance du navigateur qui décide, en interne, quand un énoncé est terminé (chaque événement `result` avec `isFinal=true` correspond déjà à "ce qui a été dit avant une pause") — même mécanisme que celui déjà éprouvé en production dans `micro.js`, repris tel quel plutôt que réinventé.
- **Restitution au fur et à mesure.** Contrairement à `micro.js` (`interimResults=false`), ce script active `interimResults=true` : le texte apparaît progressivement dans une fenêtre dédiée (fond noir, texte blanc, `overflow-y:auto`) pendant la dictée, la ligne se fige (et s'enregistre) dès que le silence est détecté, puis une nouvelle ligne démarre.
- **Aucune suppression possible, y compris pour un essai raté.** Décision explicite de Jean-Marc, à contre-courant d'une première proposition de Claude (bouton de suppression pour nettoyer un essai avorté) : *"les dictées ratées feront partie de la vie des futurs utilisateurs"* — la banque doit refléter la réalité, pas une version nettoyée qui fausserait la conception des règles de correction à venir.
- **Fenêtre vidée à chaque fermeture du micro**, rouverte vide au prochain clic — purement un retour visuel immédiat, la table continue évidemment d'accumuler tous les essais d'une session à l'autre.
- **Thème fixé** à la valeur par défaut de la colonne (`'Gestion des Contacts'`), non transmis par le script — évite de dupliquer une valeur qui doit rester pilotée par la table.
Branché, comme détaillé en section 28, sur le micro de l'écran de fond (`#micro-desk`) à la place de `micro.js`, le temps de cette phase — décision explicite de Jean-Marc de réutiliser ce micro plutôt que de construire un écran séparé, pour permettre des essais rapides et répétés en se décontractant sur le sujet. `micro.js` n'est pas modifié pour ça (seul le repli d'id ajouté plus tôt dans la soirée y reste, voir section 28) et continue de tourner normalement sur `liste_contacts.html`.
Endpoint `dictees_test.php` : reçoit `{"texte": "..."}`, enregistre tel quel dans `ASP_DicteesTest` avec l'utilisateur de la session — même squelette que `agent_notes.php` (session requise, POST uniquement), sans logique de lot ni de type de ligne (chaque énoncé est indépendant ici, pas de notion d'ouverture/clôture).
Testé par Claude en navigateur headless avec un faux moteur de reconnaissance piloté à la main (restitution en direct, figeage de ligne au bon moment, deuxième essai qui ne recouvre pas le premier, fenêtre bien vidée à la fermeture) avant livraison. **Premier essai réel le soir même, concluant** : micro laissé ouvert par Jean-Marc pour une session de dictées, une phrase dite par sa compagne dans la pièce correctement captée (voir section 28) — confirme que l'écoute continue et le redémarrage automatique tiennent la route en usage prolongé, pas seulement en essai bref.
### Fichiers touchés (3 septembre, soirée)
`micro_test.js` (nouveau), `dictees_test.php` (nouveau), `ecran_fond.html` (charge `micro_test.js` à la place de `micro.js`, voir section 28), `version.php` (`V1.010`). Table `ASP_DicteesTest` créée par Jean-Marc directement en base.
**Reste à faire.**
- Laisser Jean-Marc constituer un vrai volume de dictées sur le thème "Gestion des Contacts" avant toute analyse — aucune règle d'interprétation à écrire tant que le corpus n'est pas jugé suffisant.
- Une fois ce corpus analysé : concevoir le catalogue d'actions pour le module Contacts (actions, paramètres attendus, lesquelles exigent une validation par nature) et le mécanisme d'appel LLM à sortie structurée correspondant (voir réflexions stratégiques, section 12).
- Reconnecter `micro.js` (vrai pilotage) sur le micro de l'écran de fond une fois cette phase de mise au point terminée — une seule ligne à changer dans `ecran_fond.html` (voir section 28).
- Étendre `MARQUEUR_FIN_LIGNE` dans `micro.js` pour accepter "puis" en plus de "ensuite" (retour d'usage de Jean-Marc, 3 septembre) — sans urgence, à faire quand on retouche `micro.js`.
- Concevoir les deux signes graphiques distincts pour les post-its "à corriger" / "à valider" évoqués plus haut — non codé, ni même définitivement choisi pour "à valider" (le point d'interrogation de "à corriger" est acté).
- Étendre l'outil de banque de dictées à d'autres thèmes (catalogue, devis...) le jour où ces modules existeront — `DCT_Theme` est déjà prévu pour ça, seule la valeur par défaut en base et le script de capture sont à dupliquer/adapter.
## 3 septembre 2026 (nuit) : dictée d'adresses mail — diagnostic en plusieurs couches et deux bugs de robustesse trouvés grâce à un diagnostic en direct (V1.011)
Reprise de session le même soir, Jean-Marc ayant commencé à utiliser réellement l'outil de banque de dictées (section précédente) pour s'entraîner sur le thème "Gestion des Contacts". Constat immédiat et sévère : la dictée d'une adresse mail échoue environ 49 fois sur 50 — bien au-delà de ce qu'un utilisateur tolérerait, quelle que soit la qualité du reste du pilotage vocal (Jean-Marc : *"si une dictée d'adresse mail pose problème dans plus de 10% des cas, la solution ne sera pas utilisée"*).
**Diagnostic établi à partir d'exports réels de `ASP_DicteesTest`, pas d'hypothèses de table** (même méthode que celle qui avait permis de trouver le bug "les loups", voir plus haut) — trois causes distinctes, à ne pas confondre :
- **Vocabulaire incohérent.** Les mots dictés pour la ponctuation ("point", "tiret", "arobase", "tiret du bas") ne sont convertis en symbole par le moteur gratuit du navigateur que de façon incohérente — parfois convertis, parfois laissés en toutes lettres, parfois avalés purement et simplement. "tiret" est en plus régulièrement confondu avec le verbe "tirer" (homophone). Vérifié auprès de la documentation officielle : Google propose bien une option dédiée à cette conversion (`enableSpokenPunctuation`) dans son API Cloud Speech-to-Text payante, mais elle n'est pas exposée dans l'API gratuite du navigateur (`webkitSpeechRecognition`) — l'application doit donc faire elle-même cette conversion.
- **Segmentation non fiable.** Le moteur découpe parfois une même adresse dictée en plusieurs lignes indépendantes, sans qu'un délai de silence permette de distinguer fiablement "il continue la même adresse" de "il change de sujet" (des écarts de 3 à 4 secondes s'observent dans les deux cas sur un même export réel).
- **Lettres avalées sur les mots inventés.** Distinct des deux points précédents et plus grave : sur un mot sans aucun support linguistique réel (ex. "mvl-tls", un sigle interne), le moteur perd parfois des lettres entières ("mvl-tls.fr" transcrit "mvtls.fr") — une perte acoustique pure, invisible dans le texte reçu et donc jamais rattrapable par un quelconque post-traitement. Explication retenue : les moteurs de reconnaissance actuels s'appuient sur un modèle de langage qui favorise les séquences ressemblant à quelque chose de connu (d'où la fiabilité, à l'inverse, de "gmail.com" — une chaîne quasi mémorisée tant elle est fréquente) ; un sigle inventé n'a aucun appui de ce genre.
**Position actée avec Jean-Marc sur ce dernier point : aucune solution, gratuite ou payante, ne peut garantir un fonctionnement parfait en toute situation** sur un mot rare/inventé — dit explicitement pour ne pas laisser croire qu'une simple bascule vers une API payante réglerait tout. Le vrai levier pour ce cas précis serait le vocabulaire personnalisé ("boosting" — Google Cloud *speech adaptation*/*phrase hints*, Deepgram *keyterm prompting*), qui permet de fournir au moteur une liste de mots attendus (noms de domaine ASPIC, société, agents...) pour augmenter leur probabilité de reconnaissance — même principe que `AGN_Variantes` déjà en place pour les agents, appliqué cette fois au vocabulaire métier. Comparatif de prix et détail de la piste API conservés dans les réflexions stratégiques (section 12, addendum du même soir) plutôt que dans ce journal technique.
**Décision : épuiser d'abord la piste gratuite avant d'envisager une API payante**, le coût en dollars étant négligeable à l'usage envisagé mais le changement d'architecture bien réel (audio à faire transiter par un serveur détenant une clé, fin de la solution 100% navigateur/sans serveur actuelle).
**"Mode adresse" ajouté à `micro_test.js`.** Plutôt que de deviner où s'arrête une adresse dans un flux libre, reprise du principe déjà éprouvé du mode notation de `micro.js` (ouverture/clôture explicites) — ici au clic plutôt qu'à la phrase, pour ne pas dépendre d'un mot de clôture lui-même mal reconnaissable :
- Activé/désactivé via un bouton dédié dans la fenêtre de test ; tant qu'il est actif, chaque fragment reconnu s'empile dans un buffer affiché en direct au lieu d'être enregistré comme une ligne indépendante — la segmentation du moteur n'a alors plus d'importance.
- Le bouton "Valider" clôt le buffer : les fragments sont concaténés, passés dans un dictionnaire de conversion (point/tiret/tiret du bas/arobase → symboles, y compris l'alias "tirer"→"tiret" restreint à ce mode précis) qui retire aussi les espaces restants, puis comparés à une forme email/domaine attendue pour un simple retour visuel vert/orange — indicatif seulement, n'empêche jamais l'enregistrement (cohérent avec la règle "aucun essai raté supprimé" actée le 3 septembre après-midi).
- Le texte brut (fragments concaténés tels que dictés) et le texte normalisé sont conservés tous les deux — nouvelle colonne `DCT_TexteNormalise` (ajoutée en base par Jean-Marc lui-même, `VARCHAR(255) NULL`), `NULL` pour toutes les lignes hors "mode adresse". Rien n'écrase la matière brute déjà actée comme précieuse pour la suite.
- Un compteur simple (conforme/non conforme) affiché dans la fenêtre donne une lecture immédiate du taux d'échec au fil des essais.
**Deux bugs de robustesse trouvés en cours de test réel, tous deux corrigés le même soir grâce à un bloc de diagnostic temporaire ajouté spécifiquement pour ça** (`DIAGNOSTIC_ACTIF`/`logDiag`, affichage en direct de chaque démarrage/arrêt/erreur du moteur avec horodatage précis — y compris les erreurs normalement masquées comme "no-speech") :
1. **La fenêtre entière était vidée à chaque redémarrage automatique du moteur**, pas seulement à l'ouverture volontaire du micro — bug préexistant à ce soir, resté invisible tant que les dictées s'enchaînaient vite, devenu très visible avec une dictée hésitante (adresse mail, beaucoup de silences de réflexion → beaucoup de redémarrages automatiques → fenêtre effacée avant d'avoir pu être lue). Signalé côté utilisateur par l'indicateur micro de Chrome clignotant en continu. Corrigé en ne réinitialisant la fenêtre qu'au tout premier démarrage suivant un clic utilisateur réel, jamais lors d'une relance automatique.
2. **Boucle de redémarrage incontrôlée**, révélée par le diagnostic une fois le premier bug corrigé : jusqu'à plus de 1800 cycles démarrage/arrêt en quelques secondes, avec des erreurs "aborted" au passage — le code d'origine relançait `reconnaissance.start()` immédiatement à chaque arrêt, sans aucun délai ni protection. Corrigé par un coupe-circuit : au-delà de 5 redémarrages consécutifs en moins de 800 ms, l'écoute s'arrête proprement avec un message explicite, plutôt que de boucler indéfiniment en silence.
**Résultat après correctifs, sur un tout petit échantillon (4 essais passés par le mode adresse) :** 2 adresses parfaitement normalisées, 1 quasi-bonne (un mot parasite collé en tête, sans lien avec la ponctuation), 1 ratée — non pas à cause d'un bug, mais parce que deux adresses distinctes avaient été dictées à la suite sans clic "Valider" entre les deux, fusionnées en une seule chaîne par le buffer (comportement voulu du mode adresse, pas un défaut). Progrès net par rapport au ~1/50 de départ, mais échantillon bien trop petit pour trancher — Jean-Marc prévoit une campagne de tests en nombre le lendemain (4 septembre) et enverra les résultats.
### Fichiers touchés (3 septembre, nuit)
`micro_test.js` (mode adresse + diagnostic temporaire + deux correctifs de robustesse), `dictees_test.php` (colonne `DCT_TexteNormalise` optionnelle en entrée), `version.php` (`V1.011`). Colonne `DCT_TexteNormalise` ajoutée par Jean-Marc directement en base (`ALTER TABLE`, communiqué par Claude, jamais exécuté côté Claude — voir convention actée en tête de journal).
**Reste à faire.**
- Laisser Jean-Marc constituer un vrai volume d'essais d'adresses mail via le mode adresse (le 4 septembre) pour mesurer un taux de réussite fiable, plutôt que de statuer sur un échantillon de 4.
- Retirer le bloc de diagnostic temporaire (`DIAGNOSTIC_ACTIF`/`logDiag`) une fois le comportement confirmé stable sur cette campagne — pas destiné à rester en l'état.
- Décider si le cas "deux adresses dictées sans clic Valider entre les deux" (voir résultat ci-dessus) mérite un traitement automatique, ou reste une simple consigne d'usage.
- Si le taux d'échec mesuré reste trop élevé après cette campagne : évaluer en conditions réelles (pas seulement sur la documentation) Google Cloud Speech-to-Text (phrase hints/boost) et Deepgram (keyterm prompting) avec le vocabulaire métier d'ASPIC — voir réflexions stratégiques, section 12.
- Le problème des "lettres avalées" sur les mots inventés (ex. "mvl-tls.fr") reste entier et sans solution de contournement identifiée à ce stade, y compris avec le mode adresse — à garder en tête pour la suite plutôt qu'à considérer comme résolu.
+300 -8
View File
@@ -25,7 +25,7 @@ Une troisième itération, dans la même session, s'appuie sur une image trouvé
### Choix techniques retenus
- **`background-size:contain`, pas `cover`.** Premier essai en `cover` : sur l'écran réel de Jean-Marc (proportions différentes de l'image), le bas de l'image était rogné. Passage à `contain` : l'image entière reste toujours visible quelle que soit la proportion d'écran, quitte à laisser une bande de la couleur du mur (échantillonnée sur la photo) de part et d'autre plutôt que de rogner.
- **Système de coordonnées calé sur l'image de référence.** Chaque élément cliquable/affiché est positionné en pixels de l'image de référence (`IMG_W`/`IMG_H`, constantes en tête de script), puis converti en position/taille réelle à l'écran par une fonction `positionner()` qui refait à la main le calcul de `background-size:contain` (comparaison du ratio image/fenêtre, calcul de la zone réellement affichée et des bandes vides). Appelée au chargement et à chaque redimensionnement. Ce pattern est appelé à être réutilisé pour tout futur objet ajouté sur ce bureau.
- **Système de coordonnées calé sur l'image de référence.** Chaque élément cliquable/affiché est positionné en pixels de l'image de référence (`IMG_W`/`IMG_H`, constantes en tête de script), puis converti en position/taille réelle à l'écran par une fonction `positionner()` qui refait à la main le calcul de `background-size:contain` (comparaison du ratio image/fenêtre, calcul de la zone réellement affichée et des bandes vides). Appelée au chargement et à chaque redimensionnement. Ce pattern est appelé à être réutilisé pour tout futur objet ajouté sur ce bureau — et l'a effectivement été dès la nuit suivante pour la pastille du classeur et la grille de post-its (voir plus bas).
- **Zone cliquable large, séparée de l'icône visuelle précisément positionnée** (cas du micro, voir plus bas) : un premier essai avec une seule zone cliquable de la taille de l'icône s'est révélé trop étroit à l'usage réel (infobulle qui ne se déclenchait qu'en survolant précisément quelques pixels) — le pattern retenu sépare un grand bouton invisible (toute la silhouette de l'objet, pour le survol/clic/infobulle) d'un petit élément visuel `pointer-events:none` posé par-dessus, calé sur le détail précis à afficher (ici le rond noir du micro), reliés par un sélecteur de fratrie CSS (`~`) pour l'effet visuel au survol.
- **Aucune donnée figée en dur** : tout ce qui s'affiche vient d'un endpoint PHP réel de la session en cours (`preferences.php`, `version.php`), jamais de texte statique.
@@ -39,23 +39,315 @@ Une troisième itération, dans la même session, s'appuie sur une image trouvé
- **Touche Échap** conservée comme échappatoire silencieuse vers `liste_todolist.html`, le temps que cet écran ne porte pas encore d'objet fonctionnel complet.
- **Version applicative incrémentée à `V1.007`** pour l'ensemble de ce lot (première modification livrée dans cette étape du travail).
### Fichiers touchés
### Fichiers touchés (essai initial)
`identification.html` (redirection post-connexion), `ecran_fond.html` (nouveau), `ecran_fond.jpg` (nouveau, image de référence 1024×457 fournie et recadrée par Jean-Marc), `preferences.php` (ajout de `USR_Prenom` en lecture), `version.php` (`V1.007`). Côté base : colonne `USR_Prenom` ajoutée à `ASP_Users` par Jean-Marc directement (voir convention actée en tête de journal sur les exports SQL transmis).
## Nuit du 2 au 3 septembre 2026 : pastille du classeur, conception et premier affichage du tableau de post-its (V1.008)
Session enchaînée directement après l'essai initial ci-dessus (même soirée, Jean-Marc n'a pas fermé la fenêtre), à l'aide du token GitHub transmis en session pour relire l'état réel du dépôt avant de reprendre — token utilisé uniquement pour cloner/consulter, jamais stocké, conformément à la consigne en tête de journal.
### Pastille "nombre de contacts" sur le classeur
Avant d'ouvrir le chantier du tableau de liège, petite demande de Jean-Marc : afficher, sous le trou cerclé de métal du classeur "Contacts", une pastille ronde avec le nombre réel de contacts de la table, dans un esprit "collée sur le classeur" — couleur homogène avec le classeur (plus foncée), chiffre blanc, liseré pour ressortir.
Mesure précise du trou et de son anneau métallique sur `ecran_fond.jpg` (centre à x=58, y=354 dans le référentiel image, anneau jusqu'à y≈365) pour caler la pastille juste en dessous, sur le même axe. Couleur de fond calculée par assombrissement (×0,55) de la teinte réelle du classeur mesurée sur la photo (rgb≈3,79,99, plus sombre en photo qu'à l'écran) plutôt que choisie arbitrairement — donne `#022b36`. Nouveau paramètre `?compte=1` sur `contacts.php` : renvoie `{"total": N}` (comptage seul, `COUNT(*)` sur `ASP_Contacts` actifs) sans charger la liste complète, pour ne pas gaspiller un appel lourd juste pour une pastille. Taille de police du chiffre adaptative selon le nombre de chiffres (2/3/4+), pour continuer à tenir dans le rond si la table grossit.
Premier rendu jugé "un peu trop présent" par Jean-Marc à l'usage réel sur son écran : diamètre réduit de 22 à 17px (référentiel image 1024×457), liseré blanc adouci (épaisseur 2px→1,5px, opacité 85%→40%). Centre de la pastille recalé à (58, 381) pour rester au milieu de l'espace disponible entre l'anneau et le bas du classeur avec la nouvelle taille, plutôt que de garder l'ancien centrage. Validé ensuite par Jean-Marc ("c'est parfait").
### Conception du tableau de liège — vision actée, partiellement codée
Confirmation importante de Jean-Marc en cours de discussion : le mur représenté sur `ecran_fond.jpg` est bien du **vrai liège** (vérifié par Claude en zoomant sur la photo — texture organique/granuleuse nette, pas une couleur unie comme l'écran le laissait presque penser à taille normale). Ce point a tranché en faveur d'un post-it avec punaise ronde plutôt qu'un simple carré posé : sur du vrai liège, la punaise est l'objet qui va avec la matière, pas une fantaisie graphique.
**Répartition de l'écran, principe retenu :** 70% de la largeur pour les 4 assistants IA (Viktor, Matilda, Léon, Nikita — voir section 27), 30% fixe pour Jean-Marc lui-même, en bande verticale partant du bord droit de l'écran (juste au-dessus du chevalet, qui porte déjà son prénom — cohérence visuelle du "coin" qui le représente). Logique assumée par Jean-Marc : les assistants doivent traiter leurs tâches très rapidement (donc peu de post-its en attente à un instant T), l'humain — "le vilain petit canard" — accumule davantage avant de traiter, donc mérite plus de place. Jean-Marc a explicitement refusé d'appliquer à lui-même une répartition dynamique par charge, préférant garder ses 30% fixes pour l'instant.
**Règles actées pour la part "assistants" (70%), non codées ce soir faute de ToDoList assistants réelle :**
- Part de chaque assistant affiché = (ses tâches en attente ÷ somme des tâches en attente de tous les assistants) × 70%.
- L'assistant par défaut (`AGN_Defaut`, aujourd'hui Matilda) est toujours le plus à gauche parmi les colonnes affichées, s'il a au moins une tâche en attente.
- Les autres assistants ayant des tâches sont classés à sa suite par charge décroissante (le plus chargé juste après le défaut).
- Un assistant sans tâche en attente **disparaît entièrement** du tableau (pas de colonne vide) — permet d'intégrer les assistants un par un sans "zone vide" frustrante à l'écran.
- Chaque assistant aura une couleur de post-it personnalisée (`AGN_Couleur`, champ déjà en cours d'ajout par Jean-Marc à `ASP_Agents` en parallèle du pilotage vocal — voir section 27). Jean-Marc, lui, garde toujours le jaune classique du post-it, jamais personnalisé.
**Clarification importante actée en discussion, vérifiée dans le code par Claude :** Jean-Marc a fait remarquer que la gestion de tâches par assistant n'est pas une nouvelle mécanique à inventer — le champ `TDL_ToDoUser` de `ASP_ToDoList` (et `TDL_RealUser`) n'a jamais été restreint à un humain ; le commentaire d'en-tête de `todolist.php` anticipait déjà qu'un agent IA puisse ajouter une tâche pour un autre utilisateur, et l'endpoint renvoie déjà toutes les tâches actives quel que soit `TDL_ToDoUser`. Il suffira, le jour venu, d'ajouter un discriminant humain/agent (pour savoir quel nom et quelle couleur afficher) plutôt que de reconstruire un système. Les écrans de gestion actuels (`liste_todolist.html`, `todolist.php` pour la consultation/ajout/modification) sont conservés tels quels — pensés pour un usage humain (formulaire), pas forcément adaptés tels quels à un usage agent le jour venu, mais ce sujet est explicitement remis à plus tard. Ils ne sont **pas reliés à la navigation** depuis l'écran de fond pour l'instant (clic sur un post-it sans effet, voir plus bas).
**Nouveau besoin identifié en cours de réflexion (non traité ce soir) :** un écran "journalier", traçant ce qui a été réalisé par chacun (humain et assistants), une fois plusieurs acteurs actifs sur la même ToDoList. Repéré par Claude comme déjà largement couvert par les champs existants de `ASP_ToDoList` — `TDL_RealDate`/`TDL_RealUser`/`TDL_RealText` donnent déjà, pour chaque tâche réalisée, qui l'a faite, quand, et un commentaire de réalisation. Une première version de ce journalier pourrait donc être une simple requête "tâches où `TDL_RealDate` n'est pas vide, triées par date décroissante", sans nouvelle table. À noter : le commentaire d'en-tête de `todolist.php` mentionnait explicitement qu'un système d'audit avait été volontairement écarté à l'origine ("non demandé à ce stade... pas de sur-ingénierie") — le besoin de ce soir est justement le moment où ce choix mérite d'être révisité, ce qui valide plutôt bien le principe.
**Conception visuelle du post-it.** Deux options proposées par Claude (image de synthèse envoyée à Jean-Marc) : carré simple avec coin corné, ou même carré avec punaise ronde en haut. La confirmation que le fond est du vrai liège a tranché en faveur de la punaise. Première proposition d'agencement en petit tas chevauché, explicitement rejetée par Jean-Marc — pas pour le principe de chevauchement (qu'il n'a jamais exclu), mais pour l'empilement en tas qui rend le survol/clic imprécis ("il faudrait une précision de tireur d'élite"). Solution retenue : chaque post-it reste dans une case de grille qui lui est propre, avec un léger jitter de rotation/décalage à l'intérieur de sa case (jamais à cheval sur la case voisine) — donne un rendu "punaisé à la main" sans jamais recréer un tas ambigu au clic.
**Capacité du tableau — refus explicite d'un ascenseur.** En discutant de ce qui se passerait si le nombre de post-its dépassait la place disponible (taille fixe, pas de plafond souhaité par Jean-Marc au départ), Claude a proposé un défilement vertical à l'intérieur de la seule bande personnelle. Jean-Marc s'y est fermement opposé : un ascenseur casserait la métaphore du vrai bureau et, pire, l'inciterait lui-même à cumuler les post-its sans jamais être poussé à les traiter ("je me connais"). Décision retenue à la place : une capacité finie et un message de blocage à la création, une fois le tableau plein — la contrainte physique du vrai bureau étant vue comme une qualité (elle force à traiter), pas un défaut à contourner.
Implémentation dans `todolist.php` : constante `CAPACITE_TABLEAU_PAR_UTILISATEUR = 20` (valeur ronde provisoire, posée avant d'avoir les vrais pixels de la grille codée — "mets-le où tu veux, il faudra le faire évoluer", dixit Jean-Marc), et vérification au POST : si le nombre de tâches actives non réalisées de l'attributaire (`TDL_ToDoUser`) atteint cette capacité, la création est refusée (HTTP 409) avec le message exact demandé par Jean-Marc : *"Ton tableau est plein, tu ne peux plus ajouter de post-it !"*. Une tâche créée déjà réalisée (cas de la journalisation rétroactive d'une tâche déjà faite, prévu de longue date dans `todolist.php`) n'est jamais bloquée — elle n'occupe jamais de post-it sur le tableau. Posée dans `todolist.php` plutôt que dans l'écran de fond : la règle vaudra donc aussi le jour où la voix créera des tâches directement, sans rien à reprendre.
### Premier affichage réel des post-its personnels
Implémenté dans `ecran_fond.html`, en s'appuyant sur le pattern `positionner()` déjà en place pour les autres éléments (voir plus haut).
- **Zone :** 30% de la largeur de l'écran depuis le bord droit (soit x:[717,1024] sur l'image de référence 1024×457), du haut de l'image (y=6) jusqu'à juste au-dessus du chevalet (y=352, chevalet à partir de y=362).
- **Grille :** 4 colonnes × 5 lignes = 20 cases, volontairement alignée sur `CAPACITE_TABLEAU_PAR_UTILISATEUR` de `todolist.php` — les deux constantes devront évoluer ensemble si l'une change. Taille d'un post-it : ~60% de la plus petite dimension de case, pour laisser de la marge au jitter.
- **Source des données :** `todolist.php` (déjà utilisé par `liste_todolist.html`), filtré côté client sur l'utilisateur connecté (`TDL_ToDoUser` comparé au `USR_Code` renvoyé par `preferences.php`) et sur les tâches non réalisées (`TDL_RealDate` vide) — l'API elle-même reste volontairement non filtrée par utilisateur (voir plus haut), le tri "ce sont MES post-its" se fait donc à l'affichage. L'ordre renvoyé par l'API (rouge avant orange, sous-trié par échéance) est conservé pour remplir la grille case par case : les tâches les plus urgentes arrivent toujours en premier, en haut à gauche de la zone.
- **Jitter déterministe :** rotation et décalage d'un post-it calculés à partir de son `TDL_Incr` (petit générateur pseudo-aléatoire "mulberry32" simplifié), et non d'un hasard pur — la disposition reste stable d'un rechargement de l'écran à l'autre, pas de "saut" visuel à chaque retour sur l'écran de fond.
- **Habillage du post-it :** punaise ronde en haut, coin corné bas-droit (léger dégradé simulant le papier qui se soulève), pastille d'urgence rouge/orange bien visible en coin haut-droit (jamais verte : une tâche réalisée quitte immédiatement le tableau, voir filtre ci-dessus), jaune classique fixe pour Jean-Marc. Pas de texte visible sur le post-it (choix explicite de Jean-Marc) : le texte de la tâche (`TDL_ToDoTxt`) n'apparaît qu'en infobulle au survol. Clic sans effet pour l'instant — navigation vers `liste_todolist.html` volontairement pas encore branchée (voir plus haut).
- **Test :** rendu vérifié par Claude avec des jeux de données simulés (9 puis 23 tâches, groupes rouge/orange mélangés, une tâche appartenant à un autre utilisateur et une tâche déjà réalisée pour confirmer que le filtrage fonctionne bien) avant envoi à Jean-Marc. Rendu confirmé "conforme à la réalité" par Jean-Marc sur les images envoyées.
**Point relevé par Jean-Marc en toute fin de session, à traiter plus tard :** les règles actuelles de calcul du groupe d'urgence (`calculerGroupe()` dans `todolist.php` — rouge/orange/vert) risquent de donner un tableau surchargé de rouge et d'orange, puisque le vert (réalisé) quitte désormais le tableau immédiatement. Pas retouché ce soir, noté pour une prochaine session.
### Fichiers touchés (nuit du 2-3 septembre)
`contacts.php` (paramètre `?compte=1`), `ecran_fond.html` (pastille classeur + zone et grille de post-its), `todolist.php` (constante et vérification de capacité), `version.php` (`V1.008`).
## 3 septembre 2026 : homogénéisation des écrans, adaptation à la nouvelle photo de bureau (V1.009)
Session dédiée, dès l'ouverture, à mettre en cohérence l'écran de fond avec la liste des contacts (bandeaux, identité utilisateur, micro) — objectif explicite de Jean-Marc : *"mettre en cohérence la gestion des contacts avec ce que l'on a mis en place hier"*. De nombreux allers-retours de correction fine, avec plusieurs incompréhensions de Claude corrigées en cours de route par Jean-Marc — détaillées ci-dessous car elles éclairent des règles de conception à retenir pour la suite.
### Nettoyage des barres de `liste_contacts.html`
Retiré du bandeau haut : l'icône menu ("hamburger", devenue inutile depuis que l'écran de fond est le véritable écran d'accueil), l'avatar/nom court, le badge société, et l'icône micro. Le bandeau haut ne porte plus que le titre, renommé de "LISTE DES CONTACTS" à **"CLASSEUR DES CONTACTS. Liste de synthèse"**, calé là où était le hamburger.
La barre de statut basse regroupe en contrepartie tout ce qui identifie l'utilisateur et l'application : société, prénom, micro (recentré au milieu de la barre — voir plus bas), logo ASPIC miniature et numéro de version. Hauteur portée de 20 à 32px pour que le bouton micro y tienne à sa taille réelle.
Touche **Échap** sur cet écran : ramène désormais à `ecran_fond.html` (au lieu de fermer l'application — comportement de l'écran d'identification, qui n'a plus lieu d'être ici puisque l'écran de fond fait maintenant tampon).
### Logo, icône Mévélia, numéro de version — une "marque" commune aux deux écrans
Le logo ASPIC, retiré du bandeau haut, réapparaît en mini format dans la barre du bas, devant le numéro de version, avec le même traitement graphique repris à l'identique sur l'écran de fond (cohérence explicitement demandée par Jean-Marc — *"ajoute de la même façon le logo dans l'écran de départ"*).
Ajouts successifs sur ce groupe (`#marque-aspic`) : icône ronde Mévélia Solutions (fournie par Jean-Marc, initialement carrée puis reprise en cercle via `border-radius:50%`, après vérification par Claude — masque circulaire en Python/PIL — que le recadrage ne coupait aucune partie du logo) placée devant le logo ASPIC ; infobulle multi-lignes au survol du groupe entier ("ASPIC Vn.nnn / Créé par Mevelia Solutions", numéro de version injecté dynamiquement) ; espacement interne réduit de moitié (8px→4px) à la demande de Jean-Marc. Icône Mévélia également posée en favicon sur les quatre écrans HTML de l'application. Onglet du navigateur renommé "ASPIC. Classeur des contacts" (au lieu de "ASPIC - Liste des taches", resté de l'époque où la ToDoList était l'écran d'accueil).
### Identité utilisateur (société, prénom) — repositionnement et corrections
Société et prénom, auparavant dans le bandeau haut (badge société en cartouche bleu, avatar prénom), sont désormais du texte simple dans la barre du bas, séparés par un "gros point" (`&#8226;`, 14px) — un entre société et prénom, un second entre prénom et le groupe `#marque-aspic`. Le badge cartouche est abandonné : même mise en forme simple pour les deux informations.
**Erreur corrigée en cours de route par Jean-Marc :** un premier essai groupait par erreur le bouton micro avec ce bloc identité en bas à droite. Correction demandée : le micro doit être **seul**, centré au milieu de toute la barre — pas dans le groupe identité — par cohérence avec le micro de l'écran de fond, posé au milieu du bureau, sans lien avec une notion d'identité. Bouton `#bouton-micro` sorti du bloc, repositionné en absolu centré (`left:50%;top:50%;transform:translate(-50%,-50%)`), icônes et couleurs reprises à l'identique de l'écran de fond (SVG glyphe-repos/glyphe-ecoute, gris au repos `#d9d9d3`, rouge `#e14a5c` en écoute, mêmes barres d'animation `pulse-barre`). Un rond de fond noir (`background:var(--cartouche-bg)`, `border-radius:50%`) a ensuite été ajouté derrière l'icône, celle-ci étant quasi invisible en gris sur gris sur le fond de la barre.
### Bug corrigé : Échap sur l'écran de fond ouvrait la ToDoList
Repéré par hasard par Jean-Marc (*"quad je tape esc dans l'écran de départ j'accède à l'écran de la todolist"*). Cause : un gestionnaire de touche Échap resté de l'époque où `liste_todolist.html` était l'écran d'accueil, jamais retiré quand l'écran de fond a pris ce rôle. Le gestionnaire a été purement et simplement supprimé — Échap sur l'écran de fond n'a désormais aucun effet de navigation.
### Correctif de cache navigateur (`.htaccess`)
En marge des demandes visuelles, Jean-Marc a signalé un problème récurrent et ancien : après dépôt d'une nouvelle version, les pages restent affichées dans leur ancien état malgré rafraîchissements classiques (F5, Ctrl-F5), un passage par la navigation privée étant jusqu'ici son seul contournement fiable. Fichier livré : `htaccess-racine-aspic.txt` (à renommer `.htaccess` et déposer à la racine de l'application) — ajoute `Cache-Control: no-cache, must-revalidate` sur les fichiers `.html`/`.js`/`.css`, pour que le navigateur revérifie systématiquement auprès du serveur plutôt que de servir une copie locale périmée. **Non confirmé déployé par Jean-Marc à la clôture de cette session — à vérifier à la prochaine.**
### Nouvelle photo de bureau — chevalet retiré, repositionnement complet
Jean-Marc a livré une nouvelle version de `ecran_fond.jpg` (mêmes dimensions 1024×457) : 13px retirés en bas de la photo (tranche de bureau), 13px de liège ajoutés en haut, et le petit chevalet — porteur jusque-là du prénom de l'utilisateur — physiquement retiré de la scène photographiée (*"je l'ai rangé dans une armoire"*).
Décalage vérifié empiriquement par Claude par analyse de pixels (Python/PIL — repérage de transitions de couleur et de boîtes englobantes sur des objets reconnaissables des deux photos) plutôt que déduit du seul énoncé de Jean-Marc : +13px uniforme sur l'axe vertical, aucun décalage horizontal, confirmé sur plusieurs objets de contrôle avant application.
Répercuté dans `ecran_fond.html` : toutes les constantes de positionnement calées sur l'image (étiquette du classeur, zone cliquable du classeur, zone et icône du micro, pastille de comptage) décalées de +13 en Y ; zone des post-its agrandie en hauteur (limite basse repoussée de y=352 à y=395) puisque le chevalet ne la borne plus. Le chevalet a été retiré intégralement : CSS, HTML, constantes JS, fonction `chargerIdentiteChevalet()` renommée `chargerIdentiteUtilisateur()` (logique chevalet proprement retirée, seule la lecture société/prénom est conservée), `chargerVersionDesk()` renommée `chargerVersionBureau()`.
**Incompréhension corrigée en cours de route par Jean-Marc, à retenir comme règle de conception :** dans un premier temps, Claude avait replacé le bloc identité (société/prénom/marque) sur l'écran de fond en le calant sur le système de coordonnées de la photo, comme les autres éléments du décor — raisonnement erroné. Rappel de Jean-Marc : *"tu affiches les infos en bas à droite à la même place que dans la liste des contacts"* — ce bloc n'est **pas** un élément photographié, c'est une donnée d'interface qui doit rester à une position fixe du viewport, identique pixel pour pixel à celle de `liste_contacts.html`, indépendamment de la photo affichée derrière. Reconstruit en conséquence : mêmes identifiants HTML que `liste_contacts.html` (pas de suffixe `-desk`), CSS `position:fixed;right:16px;bottom:10px`, entièrement sorti de la fonction `positionner()` (qui ne recalcule plus que les éléments réellement ancrés sur la photo).
### Alignement horizontal entre les deux écrans — bug du `--sb-width`
Après déploiement et test réel par Jean-Marc (*"tatillon, je sais"*), écart horizontal constaté entre les deux écrans sur ce même bloc identité : décalé vers la droite sur la liste des contacts par rapport à l'écran de fond. Cause identifiée : `liste_contacts.html` calait le bloc avec `right:calc(var(--sb-width) + 10px)`, `--sb-width` étant en réalité la largeur de l'ascenseur *interne* de la liste (mesurée en JS, servant à l'origine à aligner l'en-tête de colonnes sur l'ascenseur du corps de liste) — une variable sans rapport avec la barre de statut du bas, qui ne porte plus aucun repère visuel d'ascenseur. Reliquat de raisonnement par réutilisation d'une variable déjà présente, plutôt qu'une vraie contrainte de mise en page. Corrigé en calant `right:16px` en dur, valeur identique à `ecran_fond.html` — vérifié par mesure de position réelle (`getBoundingClientRect()`) : les deux blocs tombent désormais exactement à `right:16px` / `left:1034,375px` sur une même largeur de fenêtre.
### Fichiers touchés (3 septembre 2026)
`liste_contacts.html`, `ecran_fond.html`, `ecran_fond.jpg` (nouvelle photo fournie par Jean-Marc), `identification.html` et `liste_todolist.html` (favicon uniquement), `mevelia-icone.png` (nouveau), `htaccess-racine-aspic.txt` (nouveau, non encore déployé), `version.php` (`V1.009`, incrémentée une seule fois pour l'ensemble de cette session).
## Point ouvert, non tranché
- La question enveloppes/Gmail (voir plus haut) portait sur la maquette liège/post-its, mise de côté par le changement de cap ci-dessus — à retrancher le jour où cette piste sera reprise, pas urgent pour l'essai en cours.
- Confirmer un jour le libellé du 4ᵉ classeur envisagé dans la maquette liège ("À faire" / ToDoList) — question qui ne se pose plus dans l'immédiat : l'essai en cours n'a pour l'instant qu'un seul classeur fonctionnel ("Contacts").
- **Nouveau (2 septembre 2026, remonté par Jean-Marc) : la "tranche du bureau" (bord avant du plan de travail, en bas de l'image) n'est pas visible à l'écran chez lui.** Jean-Marc précise que ça ne le gêne pas dans l'immédiat, mais que ça pourrait poser problème plus tard — à revoir un jour. Cause non investiguée à ce stade (a priori liée à la proportion réelle de son écran par rapport à l'image, malgré le passage en `background-size:contain` qui garantit que l'image entière reste visible — reste à vérifier si la bande en question est simplement hors du cadre visible dans son cas, ou une autre cause).
- La "tranche du bureau" (bord avant du plan de travail) n'était pas visible à l'écran chez Jean-Marc sur l'ancienne photo — non revérifié depuis le recadrage du 3 septembre (13px retirés en bas), qui a pu déplacer ou atténuer le problème sans que ce soit son objet. À reconfirmer à l'usage.
- Répartition dynamique des 70% entre assistants par charge de tâches (formule actée, voir plus haut) — pas codée, bloquée tant que la ToDoList des assistants n'existe pas réellement.
- Discriminant humain/agent à ajouter à `ASP_ToDoList`/`todolist.php` pour qu'un assistant puisse être attributaire d'une tâche — préalable à la répartition dynamique et aux couleurs de post-it par assistant (`AGN_Couleur`).
- Écran "journalier" (trace des réalisations, humain + assistants) — donnée déjà quasi disponible, écran à construire.
- Règles de calcul des pastilles rouge/orange/vert à revoir (voir remarque de Jean-Marc, nuit du 2-3 septembre).
- Comportement du clic sur un post-it à décider (aujourd'hui inerte) : ouvrir `liste_todolist.html` sur la tâche précise, ou détail affiché directement sur l'écran de fond.
- Capacité du tableau (20) et taille de la grille (4×5) sont des valeurs provisoires posées avant d'avoir les vrais pixels de la version aboutie — à ajuster ensemble après usage réel prolongé.
- Déploiement du correctif `.htaccess` de cache (voir ci-dessus) — à confirmer par Jean-Marc.
- Documentation utilisateur : certains passages décrivant l'écran "Liste des contacts" comme écran d'accueil et le menu hamburger sont devenus obsolètes suite aux changements du 3 septembre — corrections ponctuelles apportées le jour même (voir document séparé), une repasse plus complète (écran de fond, panneau détail, ToDoList, pilotage vocal — aucun de ces écrans n'est encore documenté pour l'utilisateur) reste à faire un jour.
## 3 septembre 2026 (suite de soirée) : micro du bureau branché sur la reconnaissance vocale réelle, puis détourné temporairement (V1.010)
Reprise de session le même jour, après la clôture ci-dessus. Premier point traité, en réponse à une question de Jean-Marc constatant l'écart de comportement entre les deux micros de l'application (celui de la liste des contacts déclenchait bien la demande d'autorisation Chrome et l'icône d'enregistrement du navigateur ; celui de l'écran de fond non) : confirmation que le micro du bureau (`#micro-desk`) n'avait effectivement jamais été branché sur `micro.js`, comme déjà noté dans "Reste à faire" ci-dessus — seul un toggle visuel local (`appliquerEtatMicroDesk()`, voir V1.007) y bascule l'icône, sans jamais appeler l'API de reconnaissance.
**Branchement réel.** `micro.js` cherchait jusqu'ici le bouton par un seul identifiant fixe (`#bouton-micro`, structure du bandeau). Plutôt que de recalquer la structure DOM de l'écran de fond sur celle du bandeau (ce qui aurait cassé la séparation volontaire zone-cliquable/icône déjà en place, voir V1.007 sur la raison de cette séparation), `micro.js` a été modifié pour chercher `#bouton-micro` puis, à défaut, `#micro-desk` — un seul fichier de comportement continue de piloter les deux écrans, chacun gardant sa structure visuelle propre. Côté `ecran_fond.html` : le toggle JS local (visuel seul) est retiré, remplacé par une règle CSS qui réagit à la classe `ecoute` que `micro.js` pose désormais sur `#micro-desk` — combinateur de fratrie (`#micro-desk.ecoute ~ #micro-icone #glyphe-repos/#glyphe-ecoute`) puisque `#micro-icone` n'est pas un descendant du bouton (voir V1.007). Vérifié par un test en navigateur headless (Playwright) : clic déclenchant bien un vrai appel à l'API de reconnaissance vocale, bascule visuelle des deux glyphes correctement pilotée par la classe.
**Détournement temporaire, le soir même.** Discussion engagée par Jean-Marc sur la fiabilité à long terme du pilotage vocal (voir réflexions stratégiques, section 12, pour le raisonnement complet) a abouti à la décision de constituer d'abord une banque de dictées réelles avant d'écrire la moindre règle d'interprétation — voir section 27 du journal pour le détail technique de cet outil (`micro_test.js`, `dictees_test.php`, table `ASP_DicteesTest`). Ergonomie proposée par Jean-Marc : réutiliser directement le micro de l'écran général plutôt qu'un écran séparé. Concrètement, `ecran_fond.html` charge désormais `micro_test.js` **à la place de** `micro.js` — `micro.js` lui-même n'est pas modifié pour ça, et reste utilisé tel quel sur `liste_contacts.html`. Pour reconnecter le vrai pilotage sur ce micro une fois la phase de mise au point terminée : remettre `<script src="micro.js">` à la place de `<script src="micro_test.js">` dans `ecran_fond.html`, rien d'autre à défaire.
**Premier essai réel concluant.** Testé en conditions réelles par Jean-Marc le soir même — micro resté ouvert, dictées répétées sans reprise en main, et une phrase dite par une tierce personne dans la pièce ("bonne nuit") correctement captée, confirmant que l'écoute continue et le redémarrage automatique du moteur fonctionnent bien en usage prolongé, pas seulement en essai bref. Point d'ergonomie mineur relevé par Jean-Marc : la fenêtre de restitution des dictées masque visuellement l'icône du micro pendant l'écoute — sans gravité, la zone cliquable réelle (le "pied" du micro, hors de la fenêtre) reste accessible pour refermer le micro. Jean-Marc a explicitement demandé de ne rien changer sur ce point.
### Fichiers touchés (3 septembre, soirée)
`micro.js` (repli d'id `#micro-desk`), `ecran_fond.html` (CSS de bascule, retrait du toggle JS local, chargement de `micro_test.js`), `version.php` (`V1.010`, incrémentée une seule fois pour l'ensemble de cette session du 3 septembre y compris sa suite de soirée). Voir section 27 pour `micro_test.js` et `dictees_test.php` (nouveaux).
## Statut et suite
**Essai fonctionnel en cours de test réel par Jean-Marc, sur son environnement de développement** (redirection post-identification vers `ecran_fond.html`, activée). Plusieurs allers-retours de correction fine déjà effectués dans la session (centrage de l'étiquette, position de l'icône micro, couleur/position du numéro de version) à partir des retours directs de Jean-Marc sur son propre écran — plusieurs points ont nécessité plus d'une itération, la mesure de position sur une photo réelle (plutôt que sur un dessin vectoriel) demandant une vérification fine à chaque retour. Fin de session actée le 2 septembre 2026 à la demande de Jean-Marc (changement de fenêtre de conversation) — pas de blocage, juste une pause volontaire.
**Trois livraisons réelles en trois jours consécutifs (2-3 septembre) :** l'essai fonctionnel initial de l'écran de fond (classeur Contacts, chevalet, micro test d'affichage, version — V1.007), la pastille de comptage et le premier affichage réel du tableau de post-its (V1.008), puis cette session d'homogénéisation des deux écrans et d'adaptation à la nouvelle photo sans chevalet (V1.009). Plusieurs allers-retours de correction fine à chaque étape, à partir des retours directs de Jean-Marc sur son propre écran ou sur des rendus envoyés par Claude — la mesure de position sur une photo réelle (plutôt que sur un dessin vectoriel) continue de demander une vérification fine à chaque retour, y compris pour des détails de mise en page fine (calage horizontal du bloc identité).
Séance close sur un bilan explicitement positif de Jean-Marc — *"content car... j'ai commencé à homogénéiser la logique des écrans, ce qui est pour moi important"*, confirmé "very well" après déploiement et test réel sur son propre environnement. Fin de session actée à sa demande, protocole habituel de clôture suivi (mise à jour du journal, copies téléchargeables, vérification du dépôt une fois le push de Jean-Marc effectué).
**Reste à faire, noté au passage :**
- Revoir un jour le sujet de la "tranche du bureau" non visible chez Jean-Marc (voir ci-dessus).
- Décider si les autres objets photographiés sur le bureau (carnet, téléphone) doivent devenir cliquables/fonctionnels dans une prochaine étape, ou rester un habillage visuel statique pour l'instant.
- Brancher réellement le micro du bureau sur la reconnaissance vocale de `micro.js` (aujourd'hui : bascule d'affichage seule, sans effet sur l'écoute réelle).
- Trancher, un jour, le sort de la piste "maquette liège + post-its par agent" (Claude Design) : abandonnée pour cet essai, mais pas explicitement enterrée pour une version plus aboutie.
- Confirmer le déploiement du correctif `.htaccess` de cache.
- Reconfirmer l'état de la "tranche du bureau" après le recadrage du 3 septembre (voir ci-dessus).
- Décider si les autres objets photographiés sur le bureau (carnet, téléphone) doivent devenir cliquables/fonctionnels, ou rester un habillage visuel statique.
- ~~Brancher réellement le micro du bureau sur la reconnaissance vocale de `micro.js`~~ — fait le 3 septembre (soirée), voir plus haut. Détourné le même soir vers `micro_test.js` (outil de mise au point, voir section 27) : à reconnecter à `micro.js` une fois cette phase terminée.
- Trancher, un jour, le sort de la piste "maquette liège + post-its par agent" (Claude Design) : mise de côté pour les essais en cours, mais pas explicitement enterrée pour une version plus aboutie.
- Artifact de travail de la maquette liège (dernière version, non utilisée dans l'essai actuel) : https://claude.ai/code/artifact/a647369f-d320-4b21-9f8c-11b20f2ad353 — lien privé à Jean-Marc, à ne pas considérer comme un livrable définitif.
- Repasse complète de la documentation utilisateur pour couvrir l'écran de fond, le panneau détail, la ToDoList et le pilotage vocal.
- Répartition dynamique par charge, discriminant humain/agent, écran journalier, règles de pastilles, comportement du clic sur un post-it, calibrage capacité/grille : voir "Point ouvert, non tranché" ci-dessus.
## 8 septembre 2026 : un bureau à plusieurs fonds interchangeables, avant-plan désormais séparé du décor (V1.16.18)
*Session du matin. Trou de documentation à noter au passage : ce fichier n'avait plus été mis à jour depuis la clôture du 3 septembre ci-dessus, alors que le classeur "Contacts" unique de l'époque est depuis devenu neuf classeurs (Consignes, Contacts, Catalogue, Documents, Prospection, Réseaux, En-cours, Factures, Journalier) dans une session intermédiaire (aux alentours du 7 septembre) jamais consignée ici — écart relevé en préparant cette mise à jour, pas comblé aujourd'hui faute de matière pour la reconstituer a posteriori. À traiter un jour si l'occasion se présente (voir "Reste à faire" plus bas).*
### Origine
Jean-Marc a exposé le sujet en ouverture, en prévenant lui-même qu'il ne le traiterait pas d'un coup ("il va bien falloir que je dorme") : l'écran de fond, tel que configuré jusqu'ici avec un seul décor (le tableau de liège), ne suffira pas à tout gérer. Trois écrans de fond sont envisagés, derrière un même avant-plan (bureau, classeurs, micro, carnet, téléphone) qui ne change pas :
- **Écran 1** : celui déjà en place (liège, post-its personnels) — reste l'écran d'ouverture.
- **Écran 2** : un futur écran "assistants" — présentation encore à concevoir, fond en tasseaux de bois gris pour l'instant.
- **Écran 3** : un futur tableau de bord — fond tableau blanc.
Navigation demandée entre ces trois fonds : deux flèches sur le bureau.
### Décisions de conception actées
Plusieurs allers-retours ont permis d'arrêter les points suivants avant tout codage :
- **Code de l'avant-plan et code des fonds séparés** — confirmé par Jean-Marc à la question de Claude.
- **Les trois fonds sont codés en dur, pas conçus comme une liste extensible** — position explicite de Jean-Marc ("je pense qu'il faut s'autoriser à coder ça en dur"), assumant qu'il n'y aura pas de quatrième fond à intégrer facilement le jour venu sans reprendre la main sur le code.
- **Animation de glissement au changement de fond**, suggérée par Claude en corollaire de la séparation avant-plan/fond, acceptée par Jean-Marc — et **le sens du glissement doit dépendre de la flèche pressée**, pas être un simple fondu neutre. Précision apportée un peu plus tard par Jean-Marc, après s'être rendu compte qu'il s'était mal exprimé une première fois : cliquer sur la flèche "écran suivant" fait apparaître l'écran n+1 **depuis la droite**, chassant l'écran actuel vers la gauche (et le mécanisme inverse pour "écran précédent") — pas l'inverse.
- **L'écran 3 garde, comme les deux autres, l'avant-plan** — un moment de doute possible sur ce point (l'écran 3 étant un futur tableau de bord, on aurait pu imaginer un plein écran sans bureau), tranché explicitement par Jean-Marc : l'avant-plan reste partout.
- **Aucune mémorisation du fond choisi d'une session à l'autre** — une nouvelle session démarre toujours sur l'écran 1, jamais sur le dernier fond consulté.
- **Position des flèches** : sur le bureau, à droite, "pas trop grosses" — affiné ensuite par plusieurs retours de taille/position une fois le rendu réel visible (voir plus bas).
- **Post-its solidaires du panneau de liège, pas de l'avant-plan.** Point important corrigé par Jean-Marc après un premier essai fonctionnel où les post-its avaient été traités par erreur comme un élément fixe de l'avant-plan (donc visibles sur les trois écrans) : *"ils seront attachés au panneau de liège, ils partiront avec lui"* — les post-its doivent apparaître et disparaître avec le fond 1 spécifiquement, jamais visibles sur les fonds 2 et 3.
### Fourniture des images et remesure intégrale
Jean-Marc a fourni six images : un avant-plan transparent (scène de bureau — classeurs, micro, carnet, téléphone — sans aucun décor de fond, à poser par-dessus n'importe lequel des trois fonds), les trois images de fond (liège actuel, tasseaux de bois gris, tableau blanc), et deux icônes de flèche. Ordre confirmé explicitement après une hypothèse initiale erronée de Claude (qui avait pris le tableau blanc pour un simple espace réservé en attendant un vrai contenu pour l'écran "assistants") : c'est bien le fond tasseaux gris qui va avec l'écran 2, et le tableau blanc, au sens propre, avec l'écran 3.
Le nouvel avant-plan étant un dessin recomposé (pas la même photo que l'ancien `ecran_fond.jpg`), l'ensemble des repères en coordonnées image (classeurs, étiquettes, zone et icône du micro, pastille de comptage, zone des post-its) a dû être **entièrement remesuré** plutôt qu'ajusté par décalage — analyse par pixels (Python/PIL/numpy : seuillage du canal alpha pour la silhouette des objets, détection de transitions/paliers de couleur pour les bords des classeurs et le centre des rectangles d'étiquette), recoupée par inspection visuelle de rendus zoomés avec grille de repérage.
### Architecture retenue
- **Couche fonds** (`#fonds-container`) : une `div.fond-slide` par fond, chacune avec sa propre image de fond en `background-size:contain` — même logique de "contain" que l'ancien fond unique, reprise telle quelle.
- **Couche avant-plan** (`#avant-plan`) : l'image transparente, fixe, `pointer-events:none`, positionnée par-dessus avec le même calcul de "contain" que les fonds (pour rester calée pixel pour pixel sur les fonds quel que soit le format d'écran).
- **Éléments interactifs** (classeurs, micro, pastille, flèches) positionnés comme avant par la fonction `positionner()`, qui recalcule à chaque chargement/redimensionnement la zone réellement affichée de l'image de référence (1024×457) et en déduit la position/taille réelle à l'écran de chaque repère.
- **Carrousel directionnel** : `changerFond(direction)` (direction = +1 ou -1) applique un `transform:translateX()` avec transition CSS sur le fond entrant et le fond sortant, dans le sens correspondant à la flèche pressée ; un `requestAnimationFrame` et une lecture forcée de `offsetWidth` (reflow) évitent que la transition ne se voie pas faute d'un état de départ bien posé ; un écouteur `transitionend` retire proprement le fond sortant du DOM une fois le glissement terminé.
- **Post-its solidaires du fond 1** : plutôt que de dupliquer la logique de transformation pour un calque de post-its qui suivrait le fond de liège, une seule `div` de post-its (`couchePostits`, une référence JS conservée en permanence) est déplacée par `appendChild` dans la slide qui représente le fond 1 au moment de sa création — `appendChild` détache automatiquement la div de son parent précédent, et la référence JS survit même quand son ancien parent DOM est retiré (`remove()`). Résultat : les post-its apparaissent et disparaissent avec le panneau de liège, sans code spécifique de synchronisation, et sans recalcul de leur position. Vérifié en test headless (Playwright) : un post-it de test injecté disparaît bien sur les fonds 2 et 3, réapparaît sur le fond 1.
- Aucune persistance du fond choisi (ni `localStorage` ni session serveur) : au chargement, l'écran s'ouvre toujours sur le fond 1.
### Retouches successives de l'avant-plan
Le premier avant-plan livré comportait des traces blanches résiduelles autour de certains objets et une zone blanche sur le micro (halo de détourage). Jean-Marc a livré une version corrigée — qui s'est révélée, à la vérification par comparaison de pixels, légèrement décalée par rapport à la première (~20-22px vers le bas sur l'ensemble de la composition, aucun décalage horizontal ; le décalage n'était pas parfaitement uniforme d'un repère à l'autre, d'où une remesure directe de chaque constante plutôt qu'un simple report d'un offset unique). Une deuxième retouche, plus tard dans la session, n'a corrigé qu'un détail de couleur (la pince du micro, passée du vert au noir) sans aucun décalage de composition — Jean-Marc l'a signalé lui-même à l'avance ("je ne change pas le fond de liège"), évitant une remesure inutile.
### Réglages fins
Comme à chaque fois qu'un élément est calé sur une image plutôt que sur un repère purement géométrique, plusieurs allers-retours de correction ont suivi la mise en ligne du premier rendu réel, chacun donné par Jean-Marc sous forme de retour concret sur son propre écran plutôt que de coordonnées :
- Taille des flèches réduite d'environ 30 %, flèches rapprochées entre elles et décalées vers la droite, puis remontées verticalement (trop proches du bord bas dans un premier essai).
- Icône du micro décalée légèrement vers la droite (position verticale déjà correcte).
- Pastille de comptage des contacts repositionnée à plusieurs reprises (la position "à mi-chemin entre le bas du trou cerclé de métal et le bas du classeur", calculée par Claude par géométrie, ne correspondait pas exactement au rendu réel vu par Jean-Marc) — trois ajustements successifs avant validation finale ("en plein dans le mille !").
- Règle de méthode reprise en cours de route pour aller plus vite : Jean-Marc donnant directement un nombre de pixels ("descends-la de 15px") plutôt qu'une description qualitative, chaque itération suivante est allée plus vite.
### Fichiers touchés
`ecran_fond.html` (restructuration majeure : couches fonds/avant-plan/post-its, carrousel, flèches), `avant_plan_bureau.png` (nouveau, remplacé deux fois en cours de session), `fond_bureau_1.jpg` (nouveau, liège), `fond_bureau_2.jpg` (nouveau, tasseaux de bois gris), `fond_bureau_3.jpg` (nouveau, tableau blanc), `fleche_precedente.png` / `fleche_suivante.png` (nouveaux), `version.php` (`V1.16.18` — `<code>` incrémenté en tout début de session à la demande de Jean-Marc, `<conception>` inchangé, cette évolution étant jugée une continuation du chantier déjà ouvert de l'écran de fond, pas une nouvelle section des Réflexions stratégiques).
### Statut en fin de session
Carrousel fonctionnel et validé par Jean-Marc sur son propre écran, après les réglages fins ci-dessus. Séance close sur un bilan positif ("je suis content du travail réalisé ce matin ! Encore une belle session !") — Jean-Marc a indiqué qu'il déposerait lui-même les fichiers sur le dépôt GitHub.
**Reste à faire, noté au passage :**
- Contenu de présentation de l'écran 2 ("assistants") — encore entièrement à concevoir, seul le fond (tasseaux de bois gris) existe à ce stade.
- Contenu réel de l'écran 3 (tableaux de bord) — seul le fond (tableau blanc) existe à ce stade.
- Combler, si l'occasion se présente, le trou de documentation relevé en tête de cette section (passage à neuf classeurs, session non consignée ici, aux alentours du 7 septembre).
- Revalider l'ensemble des repères de l'avant-plan (classeurs, micro, pastille, flèches) si Jean-Marc devait encore retoucher cette image à l'avenir — toute nouvelle version demande, comme cette fois, une vérification de décalage avant de corriger au jugé.
## 9-10 septembre 2026 : écran 2 "assistants" — portraits, couleurs, rôles, panneau "Bureau des assistants" ; nouvelle texture de fond ; réorganisation des 9 classeurs (V1.18.19)
*Réflexion stratégique associée à la réorganisation des classeurs : Réflexions stratégiques, section 19.*
### Nouvelle texture de fond pour l'écran 2
Jean-Marc a fourni une nouvelle image de tasseaux de bois pour `fond_bureau_2.jpg` (générée par un outil externe, filigrane "Pippit AI" à retirer). Filigrane retiré par échantillonnage d'une couleur de fond propre (pixel de contrôle à l'écart de tout motif) et peinture d'un rectangle de cette couleur sur l'emprise exacte du logo, avant recadrage à la spécification 1024×457 (recadrage `(127,90,1216,576)` sur la source 1344×576). Jean-Marc a préféré cette texture à un essai précédent aux tasseaux plus épais, jugés trop proches d'un aspect "planche de décoffrage" que d'une vraie décoration de bureau.
### Conception des portraits d'assistants — de la maquette au rendu final
Jean-Marc a fait générer par un outil externe (Pippit) les portraits de plusieurs assistants, avec la même problématique de filigrane à retirer que pour le fond. Plusieurs itérations de maquette avant intégration réelle dans le code, chacune corrigée à partir d'un retour direct de Jean-Marc sur l'image envoyée :
- **Répartition en colonnes.** Proposition initiale à 4 colonnes de largeur uniforme (une par assistant existant), écartée par Jean-Marc au profit de 5 colonnes dès le premier essai réel — la 5ᵉ laissée vide, faute d'un nom pour un futur cinquième assistant (Jean-Marc a écarté "Lilou", pourtant "un parfait 5ème élément", à cause de la confusion déjà vécue avec "Gilou" lors du pilotage vocal, voir journal section 27).
- **Cadrage de la photo.** Un premier essai en médaillon rond a été retenu, recentré (visage au milieu du médaillon, même hauteur au-dessus de la tête), puis abandonné au profit d'un cadrage rectangulaire façon "photo d'identité" une fois les photos de Sophia et Léon reçues au même format. Un entourage de couleur par assistant a été testé puis retiré à la demande de Jean-Marc ("l'entouré de couleur c'est pas terrible en fait"), pour ne garder qu'un simple mat blanc, dans le même esprit que les cadres déjà utilisés sur ce bureau. Le cadrage a ensuite été affiné à deux reprises : d'abord pour montrer toute la hauteur de la photo (buste), puis, à l'inverse, pour réduire légèrement l'espace au-dessus de la tête et réduire la taille globale de la photo de 15%.
- **Couleurs pastel par assistant.** Codes calculés en HSL (saturation 75%, luminosité 80%) pour rester visuellement homogènes avec le jaune post-it déjà en place (`--postit-jaune`), avec une teinte par agent : bleu ciel (`#A6D9F2`, Matilda), vert clair (`#A6F2B3`, Léon), violet clair (`#C6A6F2`, Viktor), rose (`#F2A6C6`, Nikita), orange clair (`#F2CCA6`, Sophia, ajoutée au dernier moment). Couleurs stockées dans la nouvelle colonne `AGN_Couleur` (voir plus bas), pas encore exploitées par cet écran (prévues à terme pour les post-its du tableau de liège, voir journal section 28 précédent, et pour les bulles de dialogue de la future Mission, voir réflexions stratégiques section 18) — seulement portées ici en anticipation.
- **Rôle affiché sous le nom.** Une ligne de texte en gras sous le prénom de chaque assistant, dans une teinte grise sobre (`#3a3a3a`) : Matilda "Administratif", Viktor "Commercial", Nikita "Réseaux", Léon "Gestion", Sophia "Communication" — texte porté par la nouvelle colonne `AGN_Fonction`, taille de police du prénom augmentée par rapport à celle du rôle pour bien distinguer les deux lignes.
- **Panneau "Bureau des assistants".** Une fois les 5 photos (4 assistants + 1 case vide) posées sur 5 colonnes, il restait un espace libre sous les deux dernières colonnes (Léon, Sophia). Jean-Marc a demandé d'y afficher, dans un encadré du même style que les photos (fond gris, bordure blanche), le texte "Bureau des assistants" — un premier essai occupant toute la zone disponible a été corrigé à la demande de Jean-Marc ("l'encadré doit être autour du texte et ne pas occuper toute la zone") : l'encadré final est dimensionné au plus près du texte (mesure réelle via un `<canvas>` de mesure déjà présent dans le fichier pour les étiquettes de classeurs) et centré dans l'espace disponible, pas étiré à ses limites.
### Intégration réelle dans l'application
Une fois le rendu validé sur maquette, passage au code réel une fois Jean-Marc en mesure de fournir la vraie structure de `ASP_Agents` :
- **Stockage de la photo :** nouvelle colonne `AGN_Photo` (choix retenu après question posée à Jean-Marc entre plusieurs options — colonne dédiée plutôt que convention de nommage implicite), contenant le nom de fichier de la photo (ex. `assistant_matilda.jpg`), éditée à la main par Jean-Marc comme `AGN_Variantes` — valeur vide tolérée côté écran (repli visuel avec un simple "?" à la place de la photo), un agent sans photo restant affiché.
- **Ordre d'affichage :** nouvelle colonne `AGN_OrdreAffichage`, pilotée par Jean-Marc indépendamment de l'ordre de création des agents (`AGN_Incr`) — utilisée pour trier l'écran des assistants (Matilda, Viktor, Nikita, Léon, Sophia) et remplace `AGN_Incr` dans le tri de `agents.php`.
- **Nombre de colonnes calculé dynamiquement :** décision prise après question posée à Jean-Marc (calcul depuis `ASP_Agents` plutôt que 5 colonnes codées en dur) — cohérent avec le principe déjà établi sur ce projet de sortir du code en dur (réflexions stratégiques, section 15) : le nombre de colonnes de l'écran assistants, la largeur de chaque colonne et la position du panneau "Bureau des assistants" sont recalculés à chaque chargement à partir du nombre réel d'agents renvoyés par `agents.php`, jamais figés à 5.
- **Nommage des fichiers photo :** convention `assistant_<prenom>.jpg` (et non `agent_<prenom>.jpg`, choix initial de Claude) — corrigée pour correspondre aux valeurs réelles saisies par Jean-Marc dans `AGN_Photo`. Fichiers de production recadrés selon une même recette précise (mesurée par analyse de pixels sur des images repères plutôt que devinée) : `pad=160, crop_h=1200, y0=19`, ratio largeur/hauteur `0.657`, watermark nettoyé par la même méthode que pour le fond (`assistant_leon.jpg`, `assistant_sophia.jpg`).
- **Architecture retenue dans `ecran_fond.html` :** nouvelle couche `#assistants-layer` (`coucheAssistants`), attachée au fond 2 par le même mécanisme de réattachement de nœud DOM déjà en place pour les post-its du fond 1 (`creerFondSlide()` déplace la bonne couche vers la bonne diapositive de fond selon l'index affiché — aucune duplication de logique de position). `chargerAgentsEcranAssistants()` interroge `agents.php` une fois au chargement et appelle `creerCartesAssistants()`, qui reconstruit une carte par agent (`.assistant-carte` : cadre photo ou repli "?", nom, rôle) sans jamais recréer le panneau "Bureau des assistants" lui-même (créé une seule fois, seules ses coordonnées sont recalculées). `positionner()` calcule, pour chaque colonne, la largeur (`dispW / nombre de cartes`), la taille de la photo (contrainte par la largeur de colonne autant que par la hauteur maximale), puis — uniquement si au moins deux colonnes sont affichées — la position du panneau "Bureau des assistants" en mesurant son texte réel via le canevas de mesure et en le centrant dans l'espace restant sous les deux dernières colonnes.
### Réorganisation des 9 classeurs
En parallèle du chantier ci-dessus, Jean-Marc a revu de son côté la répartition des 9 classeurs du bureau — voir Réflexions stratégiques, section 19, pour le raisonnement business complet (classement suivant le cycle de vie d'une affaire plutôt qu'un inventaire hétérogène d'objets). Nouvelle liste, mêmes 9 positions physiques sur l'avant-plan qu'auparavant (seuls les libellés/identifiants du tableau `CLASSEURS` changent, aucune coordonnée x1/x2/étiquette retouchée) : Catalogue, Documents, Contacts, Missions, Propositions, Livraisons, Factures, Journalier, Gestion. Ce nouveau libellé referme, pour sa part la plus visible, le trou de documentation relevé le 8 septembre ci-dessus (le passage à 9 classeurs vers le 7 septembre n'avait jamais été consigné ici) — même si le détail de cette session intermédiaire elle-même reste non reconstitué.
**Point de vigilance identifié et corrigé avant livraison :** la pastille de comptage des contacts (`PASTILLE_CONTACTS_CENTRE_X = 101`, positionnée sous l'ancien classeur "Contacts" en 2ᵉ position) est une coordonnée indépendante du tableau `CLASSEURS`, pas dérivée de sa position — un simple renommage des libellés l'aurait laissée sous "Documents" (nouvelle 2ᵉ position) au lieu de suivre "Contacts" à sa nouvelle 3ᵉ position. Repositionnée à `138.5` (Y inchangé) après vérification.
### Fichiers touchés
`fond_bureau_2.jpg` (nouvelle texture), `agents.php` (colonnes `AGN_Photo`/`AGN_OrdreAffichage`/`AGN_Couleur`, tri par `AGN_OrdreAffichage`), `ecran_fond.html` (couche assistants, cartes dynamiques, panneau "Bureau des assistants", tableau `CLASSEURS` relabellisé, `PASTILLE_CONTACTS_CENTRE_X` repositionnée), `assistant_matilda.jpg` / `assistant_viktor.jpg` / `assistant_nikita.jpg` / `assistant_leon.jpg` / `assistant_sophia.jpg` (nouveaux, photos de production recadrées). Côté base : colonnes `AGN_Photo`, `AGN_OrdreAffichage`, `AGN_Couleur` ajoutées par Jean-Marc directement à `ASP_Agents` (voir convention actée en tête de journal sur les exports SQL transmis).
### Statut en fin de session
Écran 2 fonctionnel côté présentation (portraits, couleurs, rôles, panneau), mais sans aucune action réelle derrière (aucun clic fonctionnel, pas d'attribution de tâches) — la présentation seule était l'objet de cette session, conformément au chantier annoncé le 8 septembre ("gérer les attributions de Matilda" reste entier, voir "Reste à faire"). `<code>` avancé dès le début de session à la demande de Jean-Marc (V1.17.18 → V1.18.18) ; `<conception>` avancé en fin de session (18 → 19) pour la réorganisation des classeurs jugée par Jean-Marc comme une évolution conceptuelle majeure (voir réflexions stratégiques, section 19) — nouvelle version **V1.18.19**.
**Reste à faire, noté au passage :**
- Gérer réellement les attributions de tâches de Matilda et des autres assistants (chantier annoncé par Jean-Marc le 8 septembre, non commencé) — discriminant humain/agent sur `ASP_ToDoList`, répartition dynamique des 70% par charge de tâches (voir "Point ouvert, non tranché" plus haut).
- Exploiter enfin `AGN_Couleur`, portée sur cet écran mais encore inutilisée par aucun affichage réel (post-its du tableau de liège, bulles de dialogue d'une future Mission).
- Contenu réel des classeurs Missions, Propositions, Livraisons et Gestion — aucun écran ni donnée derrière à ce stade, seule la classification est fixée (voir réflexions stratégiques, section 19).
- Reconstituer, si l'occasion se présente, le détail de la session intermédiaire (~7 septembre) qui avait fait passer d'un classeur unique à 9 classeurs — non fait, faute de matière, comme déjà noté le 8 septembre.
## 10 septembre 2026 (soir) : "collaborateurs" — construire et tester l'identité de chaque assistant, premier dialogue réel avec l'API Anthropic (V1.19.21)
*Réflexion stratégique associée : Réflexions stratégiques, section 20 (identité des collaborateurs — le chemin médian entre agent robotique et avatar-compagnon) ; section 21 (stockage de documents PDF, discussion architecturale indépendante de cette session technique, pas de code produit sur ce second sujet).*
### "Assistants" → "collaborateurs" : habillage seul, confirmé
Dans la continuité du glissement de vocabulaire déjà amorcé ("agents" → "assistants"), Jean-Marc a voulu franchir une dernière étape : "collaborateurs". Confirmé dès le départ comme un changement strictement côté présentation — `ASP_Agents`, les colonnes `AGN_*` et `agents.php` restent inchangés ("c'est notre cuisine interne") ; seul le libellé du panneau change ("Bureau des assistants" → "L'équipe des collaborateurs").
### Deux outils plein écran, intégrés à l'écran des collaborateurs plutôt qu'en outil à part
Jean-Marc a explicitement écarté l'idée d'un outil interne séparé (comme `dictees_test.php`/`contact_ia_test.html`), de peur qu'un outil isolé "fasse passer à côté de plein de choses" : les deux mécanismes suivants prennent la forme de deux boutons ajoutés sous le panneau des collaborateurs, chacun ouvrant un même overlay `#overlay-collab` en plein cadre (efface l'intégralité du bureau, y compris l'avant-plan), avec les 5 photos de collaborateurs en tête et un corps de fenêtre différent selon l'outil :
- **"Construire une identité"** — sélection d'un collaborateur en tête, puis un grand terminal de saisie (`<textarea>`) pour écrire ou modifier son texte d'identité, boutons Abandonner/Valider. Écriture via l'endpoint `collaborateurs_identite.php` (nouveau, PUT sur `AGN_Identite`).
- **"Tester l'identité"** — même sélection, puis une vraie fenêtre de dialogue façon session de chat (bulles utilisateur/collaborateur), branchée sur l'API Anthropic pour un dialogue réel avec le collaborateur choisi, prenant en compte l'identité déjà enregistrée. Bouton Fermer, retour à l'écran des collaborateurs dans les deux cas.
`collaborateurs_identite.php` reste un endpoint interne (session ouverte suffisante, pas de contrôle par rôle — décision explicite de Jean-Marc tant qu'il reste seul utilisateur réel de l'application, à revoir le jour d'un second compte MIRADOR, voir en-tête du fichier) volontairement séparé d'`agents.php` : `AGN_Identite` n'est jamais renvoyée à l'écran des collaborateurs standard ni à aucun autre écran utilisateur — décision de confidentialité actée dès la conception (réflexions stratégiques, section 20) : le parcours de vie/la personnalité d'un collaborateur ne doit jamais être consultable tel quel, seule une présentation synthétisée par le collaborateur lui-même, en dialogue, pourra un jour en être tirée.
### Bug critique trouvé par test automatisé avant livraison : la cascade CSS `[hidden]` perdait toujours face à `display:flex`
Avant toute livraison, une vérification par navigateur headless (Playwright) a révélé un bug qui serait resté invisible à la seule relecture du code : `#overlay-collab{display:flex}` et `.overlay-collab-corps{display:flex}` — des règles d'auteur, nécessaires pour que l'overlay s'affiche correctement une fois ouvert — **l'emportaient systématiquement sur la règle native du navigateur `[hidden]{display:none}`, quelle que soit la spécificité du sélecteur**, parce que l'origine d'une règle (auteur vs agent utilisateur) prime sur la spécificité dans la cascade CSS. Conséquence en pratique : l'overlay plein écran restait **toujours affiché et interceptait tous les clics sur l'ensemble du bureau**, y compris avant toute ouverture volontaire, dès le chargement de la page — bug détecté quand un clic Playwright sur la flèche suivante a échoué avec un message explicite ("element intercepts pointer events", pointant vers `<div hidden="" id="overlay-collab">`). Corrigé par l'ajout de deux règles explicites `#overlay-collab[hidden]{display:none;}` et `.overlay-collab-corps[hidden]{display:none;}`, chacune commentée pour expliquer le mécanisme de cascade en cause — revérifié ensuite par un scénario Playwright complet (ouverture/fermeture des deux outils, lecture de `getComputedStyle().display` à chaque étape, aucune erreur JS).
### `collaborateur_dialogue.php` — premier dialogue réel avec l'API Anthropic dans ASPIC
Nouvel endpoint, sur le même schéma défensif que le reste du projet : session ouverte requise, méthode POST uniquement, `AGN_Incr` et l'historique de la conversation (`messages`) validés avant tout envoi. Prompt système construit dynamiquement à partir de `AGN_Prenom`/`AGN_Fonction`/`AGN_Identite`, avec des garde-fous explicites : le collaborateur reste un collègue professionnel, jamais un assistant IA générique ni une figure de compagnon ; il ne doit jamais réciter ni paraphraser son texte d'identité mot pour mot même si on le lui demande directement, toujours reformuler brièvement dans ses propres mots ; il peut avoir et exprimer des avis divergents de ceux de Jean-Marc ou d'un autre collaborateur (trait de caractère assumé, réflexions stratégiques section 20 : "magnifique, la vraie vie !"). Constantes : modèle `claude-sonnet-5`, `https://api.anthropic.com/v1/messages`, `anthropic-version: 2023-06-01`.
**Incident de production réel, diagnostiqué et résolu le soir même.** Premier essai de Jean-Marc (dictée d'une identité pour Matilda, puis test du dialogue) : erreur HTTP 400 de l'API Anthropic, *"This API key is not scoped to a workspace, so this request must include the anthropic-workspace-id header..."* — diagnostiqué comme une clé API scopée "organisation" plutôt que liée à un espace de travail précis. Deux remèdes possibles proposés à Jean-Marc : recréer la clé en sélectionnant un espace de travail à la création (aucun changement de code), ou ajouter une variable d'environnement `ANTHROPIC_WORKSPACE_ID` (implémentée en secours dans le code, header `anthropic-workspace-id` ajouté seulement si cette variable est définie). Jean-Marc a choisi la première option ("Je propose de faire simple, recréer la clé !") — confirmée fonctionnelle immédiatement après ("Cela fonctionne parfaitement !").
### Affinage de mise en page demandé après un vrai test en conditions réelles
Une fois les deux outils validés en usage réel, Jean-Marc a demandé un resserrement précis de l'espace vertical disponible pour la saisie/le dialogue, point par point : titre "identité de..." remonté en haut de l'overlay, espace réduit entre ce titre et la ligne des 5 photos (photos et texte sous les photos non retouchés), haut de la fenêtre de saisie/dialogue remonté, boutons descendus au plus bas dans les deux outils, zone de saisie du message de "Tester l'identité" agrandie à deux lignes, boutons Envoyer et Fermer mis côte à côte plutôt que l'un sous l'autre. Traduit en CSS par un resserrement des paddings/marges du haut de l'overlay (`#overlay-collab` : 28px→10px de padding vertical ; titre et ligne de photos : marges basses réduites) et une restructuration du bloc de saisie du chat (`#chat-input` en bloc pleine largeur autonome plutôt que dans une ligne flexible partagée avec les boutons, `rows="2"`, boutons Fermer/Envoyer réunis dans la même ligne sous la zone de texte).
### Capacité du champ `AGN_Identite` et indicateur d'occupation
En testant, Jean-Marc s'est interrogé sur la capacité réelle du champ (`TEXT` MySQL à l'origine, 65 535 octets) — sans besoin de saturation identifié dans l'immédiat, mais avec l'idée d'ajouter un indicateur visuel par prudence. Claude a d'abord qualifié cette limite de "largement inatteignable", ce que Jean-Marc a corrigé : l'intention de fond est que l'identité d'un collaborateur **s'enrichisse avec l'usage réel de l'application** (missions menées, réunions, compétences acquises — voir la Mission, réflexions stratégiques section 18) plutôt que de rester une biographie figée écrite une fois pour toutes — ce qui change la nature de la question (un plafond qui semble haut aujourd'hui peut devenir réel sur la durée de vie d'un collaborateur). Conséquence actée le soir même : colonne élargie par Jean-Marc directement en base (`ALTER TABLE ASP_Agents MODIFY COLUMN AGN_Identite MEDIUMTEXT NULL DEFAULT NULL;`, 16 777 215 octets), et ajout d'un compteur d'occupation sous la zone de saisie de "Construire une identité" (`#compteur-identite`), recalculé à chaque frappe, comptant les octets réels au sens de l'encodage UTF-8 stocké en base (`utf8mb4`, via `TextEncoder`, pas un simple compte de caractères — un caractère accentué ou spécial pouvant occuper plusieurs octets) : affichage neutre en usage normal, orangé au-delà de 90% de la capacité, rouge et bouton "Valider" désactivé au-delà de la limite — garde-fou également revérifié côté JavaScript avant tout envoi, pas seulement affiché.
### Fichiers touchés
`ecran_fond.html` (refonte complète de la logique d'interaction de l'overlay collaborateurs — structure DOM, positionnement, ouverture/fermeture, correctif de cascade CSS, resserrement de mise en page, compteur d'occupation), `collaborateurs_identite.php` (nouveau — lecture/écriture de `AGN_Identite`), `collaborateur_dialogue.php` (nouveau — dialogue réel via l'API Anthropic). Côté base : colonne `AGN_Identite` ajoutée par Jean-Marc à `ASP_Agents` en `TEXT` puis élargie en `MEDIUMTEXT` le soir même (voir ci-dessus).
### Statut en fin de session
Les deux outils validés par Jean-Marc en conditions réelles ("les deux outils fonctionnent parfaitement et passer de l'un à l'autre est vraiment idéal"), après correction du bug de cascade CSS et de l'incident de clé API. `<code>` avancé en tout début de cette même session (V1.18.19 → V1.19.20) pour le code livré ce soir ; `<conception>` avancé deux fois au cours de la même session, de 19 à 21, pour l'ouverture des sections 20 et 21 des Réflexions stratégiques — nouvelle version **V1.19.21**.
**Reste à faire, noté au passage :**
- Rédaction des cinq textes d'identité eux-mêmes — Jean-Marc les écrit lui-même, à la première personne plutôt qu'à la troisième (choix motivé : un texte écrit comme les propres notes intimes du collaborateur aide davantage le modèle à en adopter la voix), Matilda déjà saisie via l'outil réel.
- Lien concret entre une Mission close (réflexions stratégiques, section 18) et l'enrichissement réel de `AGN_Identite` au fil du temps — intention actée (section 20), rien conçu ni codé à ce stade.
- Stockage de documents utilisateur (PDF) — réflexion architecturale actée (réflexions stratégiques, section 21 : stockage hors racine servie par le serveur web, endpoint PHP dédié, sous-dossier opaque par client en défense en profondeur), aucun développement engagé.
@@ -0,0 +1,68 @@
# 29. Interprétation vocale des contacts par LLM
*Section démarrée le 4 septembre 2026, dans le prolongement direct de la section 27 (Pilotage vocal) et de la banque de dictées constituée sur "Gestion des Contacts" (`micro_test.js`/`dictees_test.php`, table `ASP_DicteesTest`, voir V1.010-V1.011). Objectif de cette session : passer de la dictée brute enregistrée à une première interprétation réelle par LLM, sans jamais créer de contact réel — c'est le chantier annoncé en réflexions stratégiques, section 12.*
## Principe retenu et périmètre volontairement restreint
Jean-Marc a validé dès le départ le principe déjà posé en réflexions stratégiques : un LLM à sortie structurée (JSON Schema strict), pas de RAG/pgvector pour cet usage (réservé à la recherche floue), catalogue d'actions comparé explicitement en prompt plutôt que deviné. Nouvel outil de mise au point, dans la continuité de `contact_ia_test.html`/`.js` (test antérieur, conservé tel quel, aujourd'hui largement dépassé par ce qui suit) : `contact_ia_interprete.php` (appel LLM), `contact_ia_valide.php` (enregistrement), `contact_ia_panel.js` (panneau flottant, remplace `micro_test.js` sur `#micro-desk`).
**Garde-fou explicite, rappelé plusieurs fois en session et documenté en tête de chaque fichier : cette chaîne n'écrit jamais dans `ASP_Contacts` ni `ASP_Professions`.** Tout passe par une table à part, `ASP_PropositionsContact` (`PCT_Incr`, `PCT_TexteBrut`, `PCT_Extraction` JSON, `PCT_Valide` JSON, `PCT_Utilisateur`, `PCT_Date`, créée par Jean-Marc directement en base, jamais par Claude). `PCT_Extraction` est la sortie brute et honnête du LLM, jamais retouchée après coup ; `PCT_Valide` est ce que Jean-Marc valide réellement après relecture — les deux ont vocation à diverger dans leur forme, c'est voulu, rien n'est perdu silencieusement (voir plus bas, redesign des actions).
## `contact_ia_interprete.php` — extraction structurée
Lit à chaque appel `ASP_Statuts`, `ASP_Professions`, `ASP_Agents` (`AGN_Incr, AGN_Nom, AGN_Fonction, AGN_Variantes`, dans cet ordre — voir plus bas pourquoi `AGN_Defaut` n'y figure pas) et `ASP_Actions`, pour construire un JSON Schema strict envoyé à OpenAI (`gpt-5.6-luna`, choisi pour son coût — modèle plus capable `gpt-5.6-terra` documenté en réserve si la qualité d'extraction se révèle insuffisante à l'usage réel, non activé pour l'instant). Le schéma couvre les champs classiques d'un contact, `profession_existante_id`/`profession_nouvelle_libelle`, `assistant_id`, et un tableau `actions[]` (voir ci-dessous). Re-validation défensive côté PHP après réponse du LLM : `CNT_Statut`, `assistant_id` et chaque `action_existante_code` sont revérifiés contre les valeurs réelles de la base avant renvoi au client — le LLM ne peut jamais faire accepter une valeur qui n'existe pas vraiment.
**Décision architecturale actée en session : l'attribution d'un assistant par défaut n'est pas le rôle de cette couche.** Premier réflexe de Claude : pré-sélectionner l'assistant par défaut (`AGN_Defaut`, aujourd'hui Matilda) côté serveur ou côté panneau quand aucun nom n'est dicté. Correction de Jean-Marc : cette couche doit rester un enregistreur honnête de ce qui a été réellement dit ; les règles métier (à qui attribuer une tâche sans assistant nommé) relèvent d'un futur traitement n8n qui transformera les propositions validées en vrais post-its, pas de cette interprétation. Revert : colonne `AGN_Defaut` retirée de la requête SQL (redevenue identique à celle d'`agents.php`), toute logique de pré-sélection retirée de `contact_ia_panel.js`, décision documentée en commentaire dans les deux fichiers.
**Détection et classement de l'action "ajouter le contact" — confirmée comme une vraie décision du LLM, pas un cas codé en dur** (question posée explicitement par Jean-Marc en fin de session, vérifiée avec lui dans le prompt réel). La consigne du prompt système précise que la création du contact lui-même ("ajoute ce contact", "crée-le", "enregistre ce contact"...) est une action à part entière, sans code dans `ASP_Actions`, à retourner avec le libellé fixe `"Ajouter le contact"` dans `action_nouvelle_libelle` (formulation figée, pour rester comparable d'une dictée à l'autre) — et que, lorsqu'elle est demandée, elle doit toujours être placée en premier dans le tableau `actions`, avant les autres actions éventuelles, car c'est en général la première chose voulue chronologiquement. Un tri côté client dans `contact_ia_panel.js` (voir plus bas) sert uniquement de filet de sécurité si le LLM ne respectait pas l'ordre, pas de mécanisme de détection.
## `contact_ia_valide.php` — le seul point d'écriture
`INSERT` unique dans `ASP_PropositionsContact`, POST JSON `{texteBrut, extraction, valide}`. Ne relit ni ne valide le contenu de `valide` (colonne JSON opaque). En-tête mis à jour en session pour documenter un changement de forme sans conséquence de schéma : `valide.actions` est passé, le même soir, d'un tableau d'objets `{action_existante_code, action_nouvelle_libelle}` à un simple tableau de libellés texte, suite au redesign de la zone actions (voir ci-dessous) — point à connaître pour qui construira plus tard le consommateur n8n de cette table.
## `contact_ia_panel.js` — le panneau flottant, nombreuses itérations dans la session
Remplace `micro_test.js` sur `#micro-desk` (`ecran_fond.html`). Construit un panneau flottant (`#panneau-contact-ia`) avec une prompt terminal en deux temps ("attente" curseur clignotant, puis "prêt" — micro ouvert, parlez), la restitution de la dictée brute, et un formulaire d'analyse (identité, statut, profession avec repli "nouvelle profession", coordonnées, assistant, actions, bouton Valider unique).
**Simplification de la zone actions.** Premier essai avec un motif complexe (une ligne par action détectée, choix dynamique existant/nouveau par ligne) jugé trop lourd pour un essai vocal rapide — objectif de cette phase étant justement de repérer les actions non encore couvertes par `ASP_Actions`, pas de construire une UI de gestion aboutie. Remplacé par 3 zones de texte libre, pré-remplies avec le texte résolu de chaque action détectée (code existant → son libellé, ou libellé nouveau du LLM), triées pour que "Ajouter le contact" arrive toujours en premier (filet de sécurité, voir plus haut) ; au-delà de 3 actions détectées, les suivantes restent intactes dans `PCT_Extraction` mais non éditables dans l'UI pour cet essai. `collecterActionsValidees()` renvoie désormais un simple tableau de chaînes.
**Dimensionnement et défilement du panneau.** Demande de Jean-Marc : que la fenêtre prenne tout de suite sa taille maximale disponible (jusqu'au bas de l'écran sous le bouton micro), l'ascenseur apparaissant ensuite si besoin, sur toute la hauteur de la fenêtre — y compris au prix de faire disparaître les deux premières lignes de prompt en cas de défilement. Résolu en fusionnant l'en-tête de prompt, la zone de restitution et le formulaire de résultat en une seule région scrollable (`.contenu-scrollable`, `flex:1; min-height:0; overflow-y:auto`) à l'intérieur d'une colonne flex de hauteur fixe, celle-ci calculée immédiatement à l'ouverture par `ajusterHauteurPanneau()` (hauteur, pas max-height, basée sur la position réelle du bouton micro moins une marge), recalculée au redimensionnement de fenêtre. Ascenseur discret personnalisé (fin, sombre) sur cette même zone.
**Lisibilité des `<option>` des listes déroulantes.** Signalé par Jean-Marc : texte blanc sur fond blanc, et blanc sur fond bleu pour l'option survolée/sélectionnée, sur le statut comme sur la profession. Corrigé par un style explicite (`option{background:#1a1a1a;color:#999}`, `option:checked,option:hover{background:#999;color:#111}`) — réserve honnête : Chrome conserve nativement sa propre surbrillance de survol pour l'option "active" quelle que soit la CSS appliquée (Firefox la respecte mieux), donc le résultat n'est pas identique à 100% d'un navigateur à l'autre, mais le cas bloquant (texte invisible) est bien résolu partout.
**Micro fermé après Valider, panneau qui se fermait aussi.** Deux frictions remontées ensemble par Jean-Marc en test réel : *"valider sort de la fenêtre ce qui n'est pas pratique pour en dicter plusieurs à la suite"*, puis *"on revient en écran vierge, mais il ne se passe rien et je pense que le micro est fermé"* (diagnostic confirmé exact). Corrigé en deux temps : le panneau ne se ferme plus jamais après un Valider réussi (retrait de l'appel qui masquait `#panneau-contact-ia`) ; une nouvelle fonction partagée `demarrerEcoute()` (factorisée depuis le clic du bouton micro) est désormais appelée automatiquement 1,8s après un Valider réussi si le micro n'a pas déjà été redémarré manuellement entre-temps — le panneau reste ouvert, prêt pour la dictée suivante, sans manipulation.
**Bouton "Abandonner" retiré.** Faisait double emploi avec la touche Échap (déjà en place pour fermer le panneau) — retiré (bouton, CSS, variable, gestionnaire), `abandonnerPanneau()` conservée telle quelle, appelée uniquement par le gestionnaire Échap.
**Arrêt automatique sur silence.** Ajout d'un minuteur de silence (`DUREE_SILENCE_ARRET_MS = 3500`, "3-4 secondes" demandées par Jean-Marc), armé seulement après le premier résultat de reconnaissance (pas dès l'ouverture du micro), réinitialisé à chaque nouveau résultat, traité comme un arrêt volontaire propre au bout de 3,5s sans nouvelle parole. Coexiste avec le coupe-circuit anti-boucle déjà en place (5 redémarrages en moins de 800ms) et l'arrêt d'inactivité absolue à 10 minutes (hérités de `micro_test.js`).
**Garde défensive ajoutée suite à une erreur en test réel.** Jean-Marc a rencontré `Cannot read properties of undefined (reading 'forEach')` en cours d'essai. Revue statique du code n'a pas permis d'isoler une cause certaine (PDO en `ERRMODE_EXCEPTION`, requête agents identique à celle, déjà fiable, d'`agents.php`) — protection ajoutée par prudence : gardes `(x || [])` sur `remplirStatuts`, `remplirProfessions` et `remplirAssistant`, sur le même principe que celui déjà en place dans `remplirActions`. Aucune récidive rapportée depuis.
**Fausse alerte : dropdown assistant vide.** Investigation engagée après un signalement de liste déroulante d'assistants vide malgré une base réellement peuplée (6 agents, 4 actifs) — hypothèse un temps envisagée d'un mauvais routage MIRADOR entre bases. Une option de diagnostic *"(aucun assistant en base)"* a été ajoutée dans `remplirAssistant()` en cours d'investigation (conservée, coût nul). Conclusion de Jean-Marc lui-même : *"c'est une erreur de copier/coller"* de son côté, pas un bug — refermé sans autre correction.
### Version applicative
`V1.012` pour l'ensemble de cette session (une seule incrémentation, conformément à la règle du journal).
## Relecture du corpus réel (11 premiers essais, fin de session)
Jean-Marc a transmis un export SQL de `ASP_PropositionsContact` (11 lignes) pour un premier bilan qualité. Constat global positif — Jean-Marc : *"les tests sont vraiment encourageants."* Points relevés par Claude à cette relecture :
- **"Ajouter le contact" confirmée fonctionnelle et bien classée** à partir de l'essai n°7 (les tout premiers essais précédaient la mise en place de la règle de classement).
- **Bug d'extraction identifié, non corrigé** : sur l'essai n°2, l'adresse mail extraite est `"Monsieur Paul lefébure@gmail.com"` — le LLM n'a ni retiré le mot d'appel dicté avant l'adresse ("Monsieur Paul"), ni supprimé l'accent sur "lefébure" pourtant couvert par une consigne explicite du prompt sur les adresses mail sans accent. Jean-Marc a dû corriger à la main. **Piste proposée par Claude, pas encore mise en œuvre** : renforcer la consigne du prompt pour aussi neutraliser les mots parasites/formules de politesse autour d'une adresse dictée, en plus de la règle d'accents déjà en place — en attente d'un feu vert explicite de Jean-Marc.
- **Coupure possible en fin de dictée** : le texte brut de l'essai n°7 se termine en plein mot ("...aspic son"), ce qui pourrait indiquer une troncature par le nouveau minuteur de silence (3,5s). Question posée à Jean-Marc ; sa réponse évoque une autre piste possible (le chat venant parfois le déranger à son bureau pendant qu'il dicte) sans trancher explicitement si ce cas précis vient du chat ou du minuteur, ni donner d'instruction de changer la valeur. **Point resté ouvert**, `DUREE_SILENCE_ARRET_MS` non modifiée dans l'attente d'un diagnostic plus net.
- **Profession "Informaticien" apparue deux fois comme nouvelle profession** sur des dictées différentes du corpus. Jean-Marc, interrogé, a confirmé que c'est une situation acceptable et attendue en l'état : une profession peut être marquée "nouvelle" plusieurs fois tant qu'un assistant n'a pas traité le post-it correspondant ; le traitement du post-it devra simplement vérifier si la profession n'a pas déjà été créée entre l'enregistrement de la note vocale et son traitement. Pas de changement de code nécessaire pour ce point.
- Différences ponctuelles `null` vs chaîne vide entre `PCT_Extraction` et `PCT_Valide` : jugées normales et sans gravité (simplification attendue au passage par le formulaire de validation).
## Point de conception pour une prochaine session (noté par Jean-Marc, non conçu ni codé)
En clôture, Jean-Marc a formulé une réflexion architecturale à reprendre plus tard : la notion d'action sur un contact devra probablement évoluer pour intégrer formellement "création du contact" comme une action à part entière — dont le classement la place toujours en premier — mais qui ne serait pas accessible **que** par le LLM : l'utilisateur humain doit aussi pouvoir la déclencher depuis un menu ou un raccourci clavier. Il faut cependant que cette action reste connue du LLM, et que le retour de celui-ci vienne s'insérer dans le même classement ordonné, pour que les ordres passés à l'assistant vocal restent cohérents avec ceux disponibles par les écrans classiques. Jean-Marc souhaite généraliser ce principe à l'ensemble des autres actions : une séparation claire entre les actions utilisables par l'utilisateur humain (menu/clavier) et celles utilisables par le LLM de saisie vocale, tout en gardant un vocabulaire d'actions unique et cohérent entre les deux voies d'accès — écho direct du constat de satisfaction qu'il tire de cette journée : *"ce qui me plaît c'est que je vois bien que j'arrive à suivre ma logique : des fonctionnalités accessibles via des menus et écrans de façon tout à fait classique et ces mêmes fonctionnalités accessibles par des ordres vocaux fiables donnés à des assistants !"* Non conçu, non codé — à reprendre dans une prochaine session.
## Reste à faire, noté au passage
- Décider (avec l'accord explicite de Jean-Marc, pas encore donné) s'il faut renforcer le prompt d'extraction pour neutraliser les mots parasites/formules de politesse dictés autour d'une adresse mail, en plus de la règle d'accents déjà en place.
- Clarifier l'origine de la coupure en fin de dictée observée sur l'essai n°7 (minuteur de silence à 3,5s vs interruption réelle) avant de décider si `DUREE_SILENCE_ARRET_MS` doit être allongée.
- Concevoir la séparation "actions utilisables par l'utilisateur humain (menu/clavier)" / "actions utilisables par le LLM de saisie vocale", avec une notion de "création du contact" formalisée comme action à part entière toujours classée en premier, connue des deux voies d'accès et cohérente entre elles (voir ci-dessus) — réflexion actée par Jean-Marc, non conçue, non codée.
- Poursuivre la constitution du corpus réel sur "Gestion des Contacts" (Jean-Marc prévoit de réutiliser ce même écran dès le lendemain pour des demandes de modification/suppression de contact) et suivre si le taux de doublons de nouvelles professions reste gérable en pratique au fil du volume.
- Étendre progressivement, comme annoncé par Jean-Marc, ce même principe d'écran (dictée + interprétation LLM + panneau de validation) aux autres applications d'ASPIC.
- Rappel toujours valable (sections 26/27/28) : `ASP_ToDoList`/`todolist.php` doivent être étendus pour qu'une tâche puisse être réellement attribuée à un assistant avant qu'un vrai post-it puisse être généré à partir d'une proposition validée de cette table — prérequis explicitement posé par Jean-Marc avant d'aller plus loin sur le traitement n8n.
@@ -0,0 +1,102 @@
## 30. Chantier des consignes — moteur générique (6 septembre 2026)
**Origine.** Reprise le soir même de la réflexion stratégique de la nuit précédente (Réflexions stratégiques, section 14) sur le pilotage vocal comme deuxième mode de fonctionnement, et de la session de réflexion du matin du 6 septembre sur la distinction entre règles intrinsèques (non modifiables) et consignes personnelles (modifiables par l'utilisateur) — voir Réflexions stratégiques, section 15, pour le fil conceptuel complet. Ce chantier est le premier cas concret construit sur cette distinction, volontairement circonscrit à la fiche contact (création/modification) pour commencer.
**Nouvelle numérotation de version actée en tout début de session.** Format à trois niveaux `V<majeur>.<code>.<conception>` (voir `version.php`, en-tête). Départ de la session : `V1.13.14`. `<code>` et `<conception>` anticipés dès le début de session (Jean-Marc savait déjà que les deux évolueraient) : `V1.14.15`.
**Deux tables créées par Jean-Marc lui-même (convention habituelle) :**
```sql
CREATE TABLE `ASP_Fonctions` (
`FON_Incr` int NOT NULL AUTO_INCREMENT,
`FON_Nom` varchar(30) NOT NULL,
`FON_Nature` varchar(12) NOT NULL,
`FON_Description` varchar(200) NOT NULL,
`FON_ParametresAttendus` varchar(200) NULL,
`FON_NomPHP` varchar(50) NOT NULL,
`FON_Active` varchar(1) NOT NULL DEFAULT 'O',
PRIMARY KEY (`FON_Incr`),
UNIQUE KEY (`FON_Nom`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE `ASP_Consignes` (
`CNS_Incr` int NOT NULL AUTO_INCREMENT,
`CNS_Contexte` varchar(40) NOT NULL,
`CNS_Portee` varchar(60) NOT NULL,
`CNS_Champ` varchar(40) NULL,
`CNS_Type` varchar(10) NOT NULL,
`CNS_Nature` varchar(12) NOT NULL,
`CNS_Moment` varchar(15) NULL,
`CNS_Visible` varchar(1) NOT NULL DEFAULT 'O',
`CNS_Modifiable` varchar(1) NOT NULL DEFAULT 'O',
`CNS_Agent` varchar(20) NULL,
`CNS_LibelleCourt` varchar(60) NOT NULL,
`CNS_TexteDefinition` TEXT NOT NULL,
`CNS_TexteAffiche` varchar(300) NOT NULL,
`CNS_Fonction` varchar(30) NULL,
`CNS_Parametres` JSON NULL,
`CNS_MessageErreur` varchar(200) NULL,
`CNS_Active` varchar(1) NOT NULL DEFAULT 'O',
`CNS_DateCreation` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`CNS_Utilisateur` varchar(3) NULL,
PRIMARY KEY (`CNS_Incr`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
Conventions retenues pour les valeurs de colonnes (choisies par Jean-Marc, différentes du style sans accent utilisé ailleurs comme `CVX_Type`) : `CNS_Type` = `Règle`/`Consigne`, `CNS_Nature` = `Traitement`/`Test`, `CNS_Moment` = `Saisie`/`Validation`, booléens en `O`/`N`. `CNS_Portee` = `'Mise à jour'` est traité comme un mot-clé signifiant "Création et Modification" par le moteur (voir plus bas), à côté de valeurs plus précises possibles plus tard (`'Création'` ou `'Modification'` seules, pour une future règle d'immuabilité). `CNS_Fonction` référence `ASP_Fonctions.FON_Nom` (texte, pas la clé technique `FON_Incr`) — plus lisible pour une inspection directe en base.
**`moteur_consignes.php` — le moteur générique.** Fonction unique `appliquerConsignes(PDO $pdo, string $contexte, string $portee, array &$data): ?string`. Lit `ASP_Consignes` (jointure `ASP_Fonctions` sur `FON_Nom = CNS_Fonction` et `FON_Active='O'`) pour le contexte/portée donnés, applique tous les Traitements actifs (silencieux, mutent `$data` par référence, dans l'ordre de `CNS_Incr`) puis tous les Tests actifs sur les valeurs déjà transformées ; retourne le message du premier test en échec (`CNS_MessageErreur`), ou `null` si tout passe. Aucune référence à un champ ou une consigne précise dans ce fichier — tout vient de la base. Limites assumées pour cette première version (documentées en en-tête du fichier) :
- pas de distinction Saisie/Validation à l'exécution (voir plus bas, décision de Jean-Marc) ;
- les consignes transverses à toute la fiche (`CNS_Champ` NULL, ex. futur contrôle de doublon) sont ignorées, à reprendre le jour d'un vrai cas ;
- un seul message d'erreur renvoyé (le premier test en échec), pas d'accumulation.
**`consignes_fonctions.php` — le catalogue de fonctions.** Bibliothèque sans dépendance (pas de session, pas de base), une fonction par entrée de `ASP_Fonctions.FON_NomPHP`. Convention : un Traitement a la signature `(string $valeur): string`, un Test `(string $valeur): bool`. Trois fonctions écrites ce soir :
- `consigne_majuscule` (`MAJUSCULE`, Traitement) — `mb_strtoupper` avec encodage explicite (un `strtoupper()` simple laisserait les accents français inchangés selon la locale serveur).
- `consigne_non_vide` (`NON_VIDE`, Test) — `trim($valeur) !== ''`.
- `consigne_premiere_lettre_majuscule` (`PREMIERE_LETTRE_MAJUSCULE`, Traitement) — "georges"→"Georges", "JOSEPH"→"Joseph", "jean-marc"→"Jean-Marc". Écrite à la main plutôt qu'avec `ucwords()` : cette fonction native PHP raisonne en octets, pas en caractères, et corromprait un prénom commençant par une lettre accentuée (ex. "Éric"). Implémentation par `preg_replace_callback` avec `\p{L}` (Unicode) après minuscule globale.
**`contacts.php` débarrassé de tout contrôle en dur.** Avant ce soir : `$nom = trim($data['CNT_Nom'] ?? ''); if ($nom === '') { ... 400 ... }` dupliqué en POST et PUT, aucun traitement de mise en forme. Après : un seul appel, `appliquerConsignes($pdo, 'Contacts', 'Création'|'Modification', $data)`, suivi d'un retour 400 avec le message tel quel si non-null. Objectif explicite de Jean-Marc, vérifié en fin de session : ajouter ou modifier une ligne dans `ASP_Consignes`/`ASP_Fonctions` change le comportement réel de l'application sans qu'aucun fichier PHP ne soit retouché, sauf écriture d'une fonction nouvelle ou bug.
**Détour et correction en cours de route : le temps réel, tenté puis abandonné.** Premier réflexe de Claude (avant clarification par Jean-Marc) : coder en dur un contrôle "nom obligatoire" réactif au `blur` du champ côté écran, en plus du contrôle serveur — mauvaise cible, Jean-Marc voulait tester le moteur générique, pas un nouveau code écrit à la main par champ. Après clarification, Jean-Marc a lui-même arbitré l'abandon du temps réel pour cette version (latence non significative pour un seul champ, mais complexité de construction jugée disproportionnée par rapport au gain — priorité donnée à la robustesse). Tout le code de validation côté client (`liste_contacts.html`) a été retiré ; `validerFormulaireContact()` se contente d'envoyer et d'afficher tel quel le message renvoyé par le serveur.
**Bug découvert (et corrigé) précisément à cause de ce retrait.** En enlevant l'ancien contrôle client ("Le nom est obligatoire.", codé en dur avant ce chantier), on a révélé que le gestionnaire d'erreur de `validerFormulaireContact()` affichait déjà, de longue date, un message générique fixe ("Erreur : impossible de créer ce contact.") au lieu du message précis renvoyé par le serveur (`data.error` était lu pour la console, jamais pour le toast). Corrigé dans les trois points d'échec (PUT, POST, catch réseau) : `afficherToast((data && data.error) || <message générique>, true)`.
**Ajustements d'interface demandés par Jean-Marc après premiers tests réels :**
- Toast d'erreur centré sur le panneau de saisie (`top:50%`, comme `#panneau-detail`) et en rouge (`var(--rouge-suppression)`, déjà utilisée pour le mode suppression) — nouvelle classe `.toast-erreur`, activée via un deuxième paramètre de `afficherToast(message, erreur)`. Limité aux erreurs de `validerFormulaireContact` (PUT/POST/catch), les autres toasts du fichier restent inchangés.
- `white-space:pre-line` ajouté à `#toast` (tous les toasts, pas seulement l'erreur) : un `CNS_MessageErreur` contenant des retours à la ligne saisis en base ne s'affichait pas correctement (HTML fond les retours à la ligne en un espace par défaut).
**Test réel réussi en fin de session, sans aucune modification de code :** Jean-Marc a ajouté trois nouvelles lignes dans `ASP_Consignes` (`NON_VIDE` sur `CNT_Prenom` et `CNT_AdrVille`, réutilisant la fonction déjà cataloguée) — comportement immédiatement actif après dépôt des fichiers déjà livrés, aucun fichier PHP/JS retouché. Preuve de concept validée par Jean-Marc : *"On peut dire qu'on n'est pas mauvais !"*
**Reste à faire, noté en fin de session du 6 septembre :**
- Concevoir un nouveau type de consigne pour les cas nécessitant un appel IA en cours de traitement (ex. retrouver le code postal d'une ville saisie sans code postal) — contrat de retour plus riche que Traitement/Test actuel (silencieux / besoin de précision / question à poser), gestion d'échec réseau, coût et latence d'un appel externe. Session dédiée à prévoir, pas un ajout mineur au catalogue existant. *(Premier incrément construit dès le lendemain, voir 7 septembre 2026 ci-dessous — pas encore le contrat complet à trois issues.)*
- Écran de gestion des consignes par le boss (au lieu de `INSERT`/`UPDATE` SQL à la main) — à construire après stabilisation du contrat ci-dessus, pour ne pas le refaire.
- Réflexion sur un catalogue restreint de consignes/fonctions avancées à proposer aux futurs utilisateurs de l'agence, sans leur ouvrir la création libre de A à Z — argument commercial identifié par Jean-Marc ("Possibilité de nouvelles consignes pour vos assistants").
- Accumulation de plusieurs messages d'erreur à la fois (le moteur ne remonte aujourd'hui que le premier test en échec).
- Gestion des consignes transverses à toute la fiche (`CNS_Champ` NULL, ex. contrôle de doublon) — non traitée par le moteur actuel.
- Distinction Saisie/Validation réellement exploitée un jour (temps réel), si le besoin redevient assez fort pour justifier la complexité écartée ce soir.
## Suite — 7 septembre 2026 : hiérarchisation, traçabilité, et premier appel IA réel dans une consigne
**Contexte stratégique complet : Réflexions stratégiques, section 17 ([Évolutions majeures]).** Ce qui suit est le compte rendu technique correspondant.
**1. Badge de chargement IA générique.** Nouvel endpoint `consignes_apercu.php` et convention de fonction jumelle : tout Traitement marqué `FON_VisuelAttenteIA='O'` dans `ASP_Fonctions` doit être accompagné d'une fonction `<FON_NomPHP>_seraitDeclenchee(array $valeurs): bool`, sans aucun appel réseau, qui répond seule à "ce traitement se déclencherait-il réellement sur ces valeurs". `auMoinsUneConsigneIaSeraitDeclenchee()` (`moteur_consignes.php`) interroge ces fonctions jumelles pour toutes les consignes actives du contexte/portée, avant même une tentative d'enregistrement — `liste_contacts.html` n'affiche le badge `#badge-ia-actif` que si la réponse est positive, jamais de façon systématique. Rendu : rond, centré à l'écran comme un toast d'erreur, 65px de côté après plusieurs essais (130 → 50 → 25 → 65), vérifiés au rendu réel via Playwright plutôt qu'en pixels devinés. `pointer-events:none` (jamais un obstacle au clic, même principe que les autres éléments purement informatifs de l'écran de fond).
**2. Hiérarchisation des trois phases d'`appliquerConsignes()`.** Bug réel découvert par Jean-Marc : une soumission vouée à échouer sur un Test déclenchait quand même, avant l'échec, un Traitement IA coûteux — appel réseau gaspillé, sans même laisser de trace puisque l'enregistrement entier était rejeté ensuite. Le moteur exécute désormais trois phases strictement séquentielles, distinguées par le seul attribut `FON_VisuelAttenteIA` déjà présent : (1) tous les Traitements de cohérence (`FON_VisuelAttenteIA != 'O'`), (2) tous les Tests (le premier échec arrête tout, avant d'atteindre la moindre étape IA), (3) seulement si tous les Tests ont réussi, les Traitements IA (`FON_VisuelAttenteIA = 'O'`). Nouvelle fonction interne `executerTraitements(array $consignesTraitement, array &$data, array &$journalisations, array &$avertissements): void`, appelée deux fois (phase 1 et phase 3) avec un sous-ensemble différent de consignes.
**3. Traçabilité de l'agent réel dans une réalisation journalisée.** `executerTraitements()` ne fait plus confiance à un nom d'agent proposé par la fonction de traitement elle-même (source d'un bug déjà corrigé : un prénom "Mat" resté codé en dur sans aucune source) — le nom journalisé dans `_realisation.agent` vient désormais systématiquement de `$c['CNS_Agent']` (la consigne active elle-même), tronqué à 15 caractères UTF-8, avec un repli sur la valeur de la fonction puis sur `'IA'` seulement si `CNS_Agent` est vide. Même principe déjà en place côté `$agentEchec` pour un Test en échec.
**4. `REA_NomAffiche` — instantané du nom affiché, jamais recalculé à la lecture.** Nouvelle colonne sur `ASP_Realisations`, remplie une seule fois à la création de chaque ligne : côté humain, `$_SESSION['USR_Prenom']` (désormais chargé et stocké en session dès `aspic.php`, à la connexion — requête étendue `SELECT USR_Nom, USR_Prenom FROM ASP_Users`) ; côté IA, le même nom d'agent que celui journalisé dans `_realisation.agent` (voir point 3). Une première approche par jointure `ASP_Users` recalculée à la lecture a été écartée (complexité de la requête, et surtout incompatibilité avec un futur agent hébergé dans une autre base MIRADOR) au profit de cet instantané écrit une seule fois — zéro requête supplémentaire à la lecture, zéro risque de base croisée. `realisations.php` (GET et POST) et `contacts.php` (`journaliserRealisationsIA()`) mis à jour en conséquence ; `liste_contacts.html` affiche `r.REA_NomAffiche || r.REA_CodeUser || ""` et la colonne "Qui ?" a été élargie de 40 à 90px pour ne plus tronquer "Jean-Marc"/"Matilda".
**5. Renommage `ASP_Agents.AGN_Nom` → `AGN_Prenom` (à l'initiative de Jean-Marc), et une régression corrigée.** Fait pour homogénéiser avec `USR_Prenom`. Claude a validé ce renommage en affirmant "aucun impact côté code" sans avoir grep le nom de colonne dans l'ensemble du projet — cinq fichiers pré-existants du pilotage vocal (`agents.php`, `micro.js`, `contact_ia_panel.js`, `contact_ia_interprete.php`, `agent_notes.php`) référençaient encore `AGN_Nom`, cassant la liste des agents ("la liste des agents n'est pas disponible !"). Tous corrigés le même soir. Leçon actée pour la suite (voir Réflexions stratégiques, section 17) : grep systématiquement le nom d'une colonne dans tout le code avant d'affirmer "aucun impact" sur un changement de schéma.
**6. `consigne_sans_chiffre()` — nouveau Test générique.** `consignes_fonctions.php` : `return trim($valeur) === '' || !preg_match('/\d/', $valeur);`. Ajoutée au catalogue (`ASP_Fonctions`, code `SANS_CHIFFRE`) puis appliquée par Jean-Marc lui-même via trois nouvelles lignes `ASP_Consignes` (Nom, Prénom, Ville) — aucun fichier PHP/JS retouché pour l'étendre à un troisième champ, confirmant une fois de plus le principe du chantier.
**7. `consigne_completer_code_postal()` — premier vrai appel IA dans une consigne, avec trace honnête en cas d'échec.** Reprend le contrat existant (`_realisation`, clé réservée déjà en place) et introduit une nouvelle clé réservée symétrique, `_avertissement` — une simple chaîne, affichée immédiatement à l'écran par le front-end sans qu'il sache jamais d'où elle vient. Quand l'IA ne peut rien déduire d'exploitable (ville ambiguë comme Paris, découpée en arrondissements) : une `_realisation` est journalisée dans tous les cas (même en échec — corrige l'anomalie signalée par Jean-Marc : un appel IA sans la moindre trace), et `_avertissement` est en plus renvoyé au front. `moteur_consignes.php` (`executerTraitements()`) extrait les deux clés en parallèle dans un nouveau tableau `$avertissements`, remonté par `appliquerConsignes()` (nouveau paramètre `?array &$avertissements = null`) puis par `contacts.php` dans la réponse JSON (`'avertissements' => $avertissementsIA`).
**8. Affichage de l'avertissement côté écran — trois itérations dans la soirée.** D'abord un toast neutre, identique aux autres. Puis rouge et centré comme un vrai message d'erreur (`afficherToast(message, true)`), et le panneau reste en mode édition au lieu de repasser en mode vue après l'enregistrement (`afficherDetailPourId(idContact, avertissements.length ? "edition" : "vue")`) — pour que l'utilisateur puisse corriger tout de suite le champ resté incomplet, `contactCourant` étant malgré tout rafraîchi pour éviter un double POST en cas de nouvelle tentative d'enregistrement. Puis, une fois testé, le message disparaissait encore trop vite et la fiche restait visuellement "non validée" alors qu'elle était bien enregistrée : `afficherToast()` accepte désormais un 4ᵉ paramètre optionnel (`onValider`) qui, s'il est fourni, supprime la disparition automatique et ajoute un bouton "Valider" (nouvelle classe `.toast-bouton`) — le clic ferme le message et exécute `onValider`, ici un simple `basculerModeDetail("vue")` (aucune revalidation, aucun nouvel enregistrement déclenché, le contact étant déjà enregistré depuis le début de la séquence). Le texte de l'avertissement, côté `consignes_fonctions.php`, a été rendu plus impératif ("Saisissez le code postal vous-même.") en cohérence avec ce fonctionnement bloquant.
**Version.** `<code>` déjà avancé en tout début de cette session de travail (`V1.14.15` → `V1.15.15`, avant même le badge IA du point 1). `<conception>` avancé en fin de session à `17` (Réflexions stratégiques, nouvelle section 17) — version finale de la session : **V1.15.17**.
**Reste à faire, noté en fin de session du 7 septembre :**
- Le contrat de retour à trois issues (silencieux / besoin de précision / question à poser), envisagé le 6 septembre, reste à concevoir dans son ensemble — ce qui a été construit ce 7 septembre n'en couvre que deux (succès silencieux, échec avec avertissement), volontairement, en attendant un second cas concret qui justifierait la généralisation.
- Tous les points laissés ouverts le 6 septembre (écran de gestion des consignes, catalogue commercial restreint, accumulation de plusieurs messages d'erreur, consignes transverses `CNS_Champ` NULL, distinction Saisie/Validation) restent entiers, aucun n'a été traité ce 7 septembre.
@@ -0,0 +1,55 @@
# 31. Compétences, Missions et premier chantier agentique — Sophia et les sites web
**Origine (10-11 septembre 2026).** En utilisant l'outil "Tester l'identité" (voir section 28, Réflexions stratégiques section 20), Jean-Marc a constaté que Sophia se mettait à lui réclamer des informations et des fichiers pour "créer des sites web" pour lui — alors qu'aucune capacité réelle ne lui avait jamais été donnée, seulement une conversation. Claude a fait remarquer que ce n'était pas un problème en soi (elle n'avait aucun moyen d'agir) mais un symptôme : rien dans son prompt système ne la bornait sur ce qu'elle pouvait réellement proposer. Point retenu comme leçon de fond pour toute la suite du chantier.
**Reformulation par Jean-Marc : ce n'est pas un besoin ponctuel, c'est un chantier de fond.** Plutôt que de traiter la demande de Sophia isolément, Jean-Marc a explicitement élargi le sujet : donner à chaque collaborateur des **compétences**, pour qu'il puisse accomplir seul (pour commencer, mono-collaborateur) des **Missions** — reprenant directement le concept déjà posé sans être conçu le 8 septembre (Réflexions stratégiques, section 18). Les deux petits outils actuels ("Construire une identité"/"Tester l'identité") ont été explicitement qualifiés par Jean-Marc de **provisoires** — utiles aujourd'hui, pas destinés à survivre tels quels dans l'application finale.
**Périmètre de la compétence, fixé explicitement par Jean-Marc avant tout schéma :**
- Sophia ne devient en aucun cas productrice de sites pour des clients tiers de l'entreprise — elle reste strictement dans le périmètre du CRM : le site produit est celui de l'entreprise qui utilise ASPIC elle-même.
- Elle ne travaille qu'avec les documents de l'entreprise — mais en creusant ce point, aucune table réelle n'existe encore derrière les classeurs "Catalogue"/"Documents" (voir section 19 : classification actée, contenu non construit). Pour cette première version, elle travaille donc uniquement à partir de ce qui lui est fourni directement dans la conversation ; un outil de lecture du catalogue/documents réels est remis à plus tard, quand ces données existeront pour de bon.
- Elle doit pouvoir mettre à jour un site déjà créé (retours clients, nouveaux produits).
- Une fois ses créations validées, elle doit pouvoir les faire **publier** — mais la publication reste un acte humain déclenché par Jean-Marc, jamais un outil laissé entre les mains du modèle (même discipline que la clôture d'une Mission, jamais calculée automatiquement, section 18).
**Modèle de données.** Cinq tables créées dans `MS-DEV` (pas encore dans `MS-TST`, synchronisation manuelle à refaire le jour venu, les deux bases ne se répliquant pas automatiquement) :
- `ASP_Competences` — catalogue curaté par Jean-Marc, même logique que le catalogue de consignes (section 15/18) : c'est le code (`mission_agent.php`), pas une donnée éditable en base, qui définit réellement ce qu'un outil fait.
- `ASP_AgentsCompetences` — jonction agent/compétence.
- `ASP_Missions` — objectif, statut (`ouverte`/`close`), collaborateur assigné, compétence mobilisée, site éventuellement lié, et un champ `MIS_Journal` (brouillon vivant, accumulé par `CONCAT`, même mécanique que `AGN_Experience`).
- `ASP_SitesWeb` — un site par ligne, `SIT_NomDomaine` plutôt qu'un slug générique : Jean-Marc a d'emblée deux domaines réels en tête (`mevelia-solutions.bzh` pour la future agence, `aspic.bzh` pour le CRM lui-même).
- `ASP_SitesWebFichiers` — métadonnées des fichiers d'un site, noms **prévisibles** (`index.html`, etc.) — volontairement différent du coffre-fort documentaire de la section 21 (noms randomisés) : là où un PDF doit rester inatteignable, un site web doit au contraire être servable.
**Convention de nommage imposée par Jean-Marc en cours de route :** chaque champ d'une table doit commencer par le préfixe de **sa propre** table, jamais celui de la table référencée par une clé étrangère — ex. dans `ASP_AgentsCompetences`, le champ pointant vers `ASP_Agents.AGN_Incr` doit s'appeler `AGC_AgentIncr`, pas `AGN_Incr`. Toutes les tables ont été (re)nommées en conséquence avant création.
**Clarification de fond sur `AGN_Identite`/`AGN_Experience`, apportée par Jean-Marc et qui affine la section 20 :** `AGN_Identite` est le profil fixe du collaborateur à l'embauche, avant toute mission — écrasable à volonté. `AGN_Experience` est ce qui se met à jour à la **clôture** d'une mission (pas l'identité, contrairement à ce que la section 20 laissait entendre). La mécanique de clôture elle-même (comment `MIS_BilanCloture` vient effectivement enrichir `AGN_Experience`) reste non codée à ce stade.
**Stockage — brouillon hors racine web, publication dans la racine.** Nouveau volume Docker `./sites_brouillons:/var/www/sites_brouillons` ajouté à `docker-compose.yml` (hors de tout chemin servi par Apache — aucune URL ne peut jamais l'atteindre, même principe que le coffre PDF de la section 21) ; `crm-app` recréé pour appliquer le volume (brève coupure de service). Un second dossier, `sites_publies`, vit lui À L'INTÉRIEUR du volume existant `./src:/var/www/html` — sans volume supplémentaire — puisqu'un site publié est, par nature, censé être atteignable par une URL.
**Incident de permissions, première fois que du code PHP écrit sur disque dans ce projet.** `/var/www/html` appartient à `1001:1001` (chown fait par la commande de démarrage du conteneur) mais Apache tourne en réalité en tant que `www-data` (uid 33, vérifié via `ps aux`) — sans droit d'écriture nulle part par défaut. Un `chmod 0777` a été posé spécifiquement sur les deux dossiers de stockage (`sites_brouillons` et `sites_publies`), jamais sur le reste de l'arborescence. Choix du `chmod` plutôt que d'un `chown` vers `www-data` : la commande de démarrage du conteneur refait un `chown -R 1001:1001 /var/www/html` à **chaque redémarrage**, ce qui aurait annulé un `chown` ciblé sur `sites_publies` (qui est sous `/var/www/html`) — un `chmod`, lui, n'est jamais touché par cette commande et survit donc aux redémarrages.
**`mission_agent.php` — la boucle agentique, fichier séparé de `collaborateur_dialogue.php`.** Décision explicite de ne jamais faire glisser le bac à sable sans effet de bord ("Tester l'identité") vers un endpoint qui écrit réellement sur disque. Différences clés avec `collaborateur_dialogue.php` :
- L'historique complet des échanges (texte ET appels d'outils, format Anthropic) est conservé côté serveur (nouvelle colonne `ASP_Missions.MIS_HistoriqueJson`, ajoutée en cours de session — besoin technique découvert, pas anticipé dans le schéma initial), pas côté navigateur : une Mission doit être reprenable depuis n'importe quel ordinateur, exigence explicite de Jean-Marc (il travaille sur plusieurs machines).
- Paquet d'outils fixé par compétence (`outilsDeLaCompetence()`), une seule câblée pour l'instant (`SITE_WEB`) : `lister_fichiers_site`, `lire_fichier_site`, `ecrire_fichier_site` — extensions autorisées limitées à `html/css/js/json/svg` (pas de PHP, pas d'exécutable ; pas d'images/binaire pour cette première version, complexité d'encodage remise à plus tard).
- Sandbox fichiers : chemin relatif uniquement, résolution par `realpath()` vérifiée sous la racine du site (protection traversée de chemin), taille max 2 Mo par fichier.
- Boucle plafonnée à 6 aller-retours d'outils par message, même discipline que `consigne_completer_code_postal()` (section 30) : ne jamais laisser un enchaînement s'emballer.
- Chaque appel d'outil (réussi ou refusé) journalisé dans `MIS_Journal` — jamais un échec silencieux.
- Création différée du site (`ASP_SitesWeb` + dossier physique) au tout premier `ecrire_fichier_site()` réussi, jamais à l'ouverture de la mission — pour ne pas laisser de site fantôme si une mission n'aboutit à rien.
**`sites_web.php` — publication, action humaine.** Liste les sites et leurs fichiers ; l'action `publier` vide le dossier publié puis le repeuple **uniquement** à partir des fichiers connus d'`ASP_SitesWebFichiers` (jamais une copie brute de dossier, qui traînerait un fichier orphelin). Le rattachement à un vrai nom de domaine (vhost Apache ou entrée dans `npm-app-1`) reste une étape manuelle de Jean-Marc, hors périmètre de cet endpoint.
**`missions.php`** — ouverture/liste des missions ; refuse explicitement la création si le collaborateur assigné n'a pas la compétence demandée.
**`mission_test.html`** — outil de mise au point interne (même esprit "terminal" que `contact_ia_test.html`), pas un écran utilisateur : le classeur "Missions" du bureau n'a toujours aucun écran réel (section 19), inutile de construire l'écran final avant que le mécanisme lui-même soit validé.
**Donnée d'amorçage.** Compétence `SITE_WEB` créée et attribuée à Sophia (`AGN_Incr = 13`).
**Premier essai réel.** Jean-Marc a d'abord tenté d'ouvrir `mission_test.html` en tapant l'URL directement, sans passer par l'écran d'identification — échec attendu ("Non identifié — veuillez vous connecter."), aucune session PHP ouverte : même règle que tous les autres écrans de l'application, rien de spécifique à cette fonctionnalité. Une fois connecté dans le même onglet, l'ouverture d'une mission a fonctionné : Sophia s'est montrée réactive, et a spontanément fait référence aux échanges précédents des sessions "Tester l'identité" — confirmation en conditions réelles que le mécanisme de mémoire (`AGN_Experience` réinjecté dans le prompt système de la mission) fonctionne comme prévu. Vérification détaillée de ce qu'elle a réellement écrit comme fichiers laissée à une prochaine session (Jean-Marc fatigué).
**Découverte en cours de route : du travail de la session précédente jamais poussé.** `git status` a révélé des modifications non committées antérieures à cette session (`collaborateur_dialogue.php`, `collaborateurs_identite.php`, `ecran_fond.html`, `version.php`, plus `collaborateur_experience_append.php` non suivi) — le chantier "collaborateurs" du 10 septembre (section 28/20) n'avait jamais été poussé sur GitHub. Signalé à Jean-Marc, décision de commit/push en attente.
**Statut.** Mécanisme construit et atteignable de bout en bout (tables, deux endpoints de gestion, la boucle agentique, un outil de test, une compétence réelle attribuée) ; premier échange réel validé comme cohérent et réactif. Points explicitement laissés ouverts :
- Clôture de mission (`MIS_Statut`, `MIS_BilanCloture`, et le mécanisme concret d'enrichissement d'`AGN_Experience`) — non conçue en détail, non codée.
- Lecture réelle du catalogue/documents de l'entreprise — dépend de classeurs encore vides (section 19).
- Rattachement à un vrai nom de domaine une fois un site publié — étape d'infrastructure manuelle.
- Support des images/fichiers binaires dans un site — non traité (texte uniquement pour l'instant).
- Synchronisation du schéma vers `MS-TST` — pas encore faite.
`<code>` avance (tables + quatre nouveaux fichiers PHP/HTML + volume Docker) ; `<conception>` avance également, une nouvelle section (22) ouverte dans les Réflexions stratégiques pour la partie conceptuelle de cette même session — nouvelle version **V1.21.22**.
@@ -0,0 +1,58 @@
# 32. BackOffice — Collaborateurs, Compétences, Espaces disque et coût IA (11 septembre 2026)
**Origine.** En regardant le BO fonctionner (voir section 31), Jean-Marc a réalisé qu'il existe désormais deux univers distincts dans ASPIC : l'écran vu par l'utilisateur final, et toute la mécanique interne (identités des collaborateurs, compétences, missions-types) qui ne doit jamais lui être accessible. Décision : sortir ces outils de mise au point de l'écran des collaborateurs (`ecran_fond.html`) et leur donner une vraie maison — un **BackOffice** (terme repris de l'expérience Prestashop de Jean-Marc), esthétique volontairement minimale ("écran noir dans l'esprit du Terminal"), avec une règle de navigation précise : chaque application du BO, en se refermant, reste dans le BO (jamais un retour direct vers la CRM) ; Échap ne sort du BO que depuis son écran de menu, quand aucune application n'est ouverte.
## Point 1 — La coquille du BackOffice
**Accès.** Nouvelle colonne `MIRADOR.MIR_Users.MIR_BO` (O/N) — lue une seule fois par `aspic.php` à l'identification, posée en session (`$_SESSION['USR_BackOffice']`), relue par `preferences.php` (déjà appelé au chargement du bureau). Un bouton discret "BO" apparaît sur le bureau (près du logo ASPIC) uniquement pour les comptes autorisés, et renvoie vers `backoffice.html`.
**La coquille elle-même** (`backoffice.html`/`.css`/`.js`) : colonne de menu à gauche, logo + version en bas (même esprit que le bureau principal), et la règle Échap décrite plus haut, centralisée dans `initBackoffice()`.
**Collaborateurs** (`bo_collaborateurs.html`) : migration à l'identique de "Construire une identité"/"Modifier l'expérience"/"Tester l'identité", débranché d'`ecran_fond.html` (718 lignes retirées — CSS, HTML de l'écran plein cadre, et toute la logique JS associée). Vérification faite après coup : accolades CSS et JS toujours équilibrées, aucune référence orpheline aux identifiants supprimés.
**Compétences** (`competences.php`, `agents_competences.php`, `bo_competences.html`) : nouveau, catalogue (créer/lister) + attribution/retrait à un collaborateur. `AGN_Competences` (le champ dénormalisé prévu ensuite) n'est volontairement pas encore câblé à ce stade — câblé le même jour en addendum, voir plus bas.
**Bug trouvé et corrigé en cours de route (point 2, mais découvert ici) :** `MIR_Code` n'est pas une clé unique dans `MIR_Users` — "JMP Dev" et "JMP Tests" partagent le même `MIR_Code` ("JMP"). Une première version de `espace_utilisateur.php`/`espaces.php` cherchait par `MIR_Code`, ce qui aurait mélangé les deux comptes. Corrigé en utilisant `MIR_Email` comme clé partout (même colonne que la vérification de mot de passe dans `aspic.php`).
## Point 2 — Espaces disque
**Origine.** Jean-Marc voulait automatiser la création d'un espace disque privé par utilisateur, organisé par instance MySQL puis par utilisateur, avec des noms de dossiers illisibles pour un fouineur. Sa première proposition (SHA256 tronqué sur le nom de l'instance + numéro d'incrément) a été discutée et écartée : SHA256 est un hash **rapide**, donc cassable par dictionnaire dès que l'univers des noms possibles est petit et connu — ce qui est le cas ici (une poignée de noms d'instances/bases).
**Solution retenue** (`espace_utilisateur.php`) :
- Dossier "tenant" (une base de travail) : **HMAC-SHA256 avec clé secrète** (`ESPACE_CLE_SECRETE`, nouvelle variable d'environnement) sur `host|baseNom` — déterministe (recalculable sans stockage), mais infaisable à deviner sans la clé. Reprend exactement le mécanisme déjà en place dans `prospects_core.php` pour les liens de suivi (`LIEN_DOC_CLE_SECRETE`, `hash_hmac`), avec sa propre clé dédiée.
- Dossier "utilisateur" : jeton **aléatoire** (`random_bytes`), jamais dérivé d'un nom, généré une seule fois au premier besoin réel puis persisté (`MIR_Users.MIR_Espace`) — même principe de création différée que `creerSiteSiBesoin()` (voir section 31).
- Stockage physique hors racine servie par Apache (nouveau volume Docker `./espaces_utilisateurs`), même principe que les brouillons de sites.
**Rassurance de Jean-Marc, anticipée avant même la construction :** inquiétude légitime de perdre l'accès à "son propre" dossier une fois les noms rendus illisibles. Réponse retenue : l'opacité protège d'un visiteur extérieur, jamais de l'administrateur qui interroge la base — d'où la page BO **"Espaces disque"** (`espaces.php`/`bo_espaces.html`), qui liste chaque utilisateur avec son chemin résolu, son statut (créé ou pas) et sa taille, plus un bouton "Provisionner" pour forcer la création immédiate.
**Testé réellement** : le dossier de "JMP Dev" existe désormais pour de vrai sur le disque (`/var/www/espaces_utilisateurs/{tenant}/{user}/`), jeton persistant confirmé en base.
**Explication donnée à Jean-Marc, pour lever un doute exprimé sur la navigation utilisateur :** ce mécanisme reste invisible du navigateur dans tous les cas — un futur accès à un fichier de cet espace passera par un endpoint dédié (URL du type `telecharger_fichier.php?id=42`), qui lit le fichier réel côté serveur et renvoie les octets, jamais un lien direct vers le chemin haché. Endpoint non construit à ce stade — seule l'architecture qui le rendra possible est posée.
## Point 3 — Cache de prompt et journalisation du coût IA (non planifié, ajouté en cours de session)
**Origine.** Jean-Marc a constaté plus de 20€ de consommation sur sa clé Anthropic sans pouvoir se l'expliquer. Deux réglages non optimisés identifiés en relisant le code :
- Aucun cache de prompt : le prompt système (identité + expérience, ou identité + expérience + brief + outils pour une mission) était renvoyé et refacturé en entier à **chaque** appel — y compris chaque tour d'une même boucle d'outils (jusqu'à 6 par message dans `mission_agent.php`).
- Aucune trace du coût réel dans l'application : ni `collaborateur_dialogue.php` ni `mission_agent.php` ne lisaient le champ `usage` pourtant renvoyé par l'API à chaque appel — la même préoccupation que celle qui avait mené à `_realisation`/`_avertissement` dans le moteur de consignes (7 septembre 2026), jamais appliquée ici jusqu'à ce soir.
**Corrections :**
- `system` transmis en tableau de blocs avec `cache_control: ephemeral`, dans les deux fichiers.
- Nouvelle table `ASP_AppelsIA` + `journal_ia.php` (tarifs vérifiés le 8 septembre 2026, ratios de cache documentés par Anthropic) : chaque appel réel (chaque tour de boucle inclus) est désormais journalisé avec ses vrais tokens et son coût estimé. Nouvelle page BO **"Coût IA"** (`appels_ia.php`/`bo_cout_ia.html`) : total général, répartition par origine, 50 derniers appels.
**Non vérifié en conditions réelles** : comme pour le reste de ces mécanismes, aucun appel réseau réel n'a pu être déclenché depuis l'environnement de développement — le calcul de coût est vérifié unitairement (test local avec des chiffres fictifs), mais le comportement réel du cache reste à observer sur le prochain essai de Jean-Marc.
## Découverte annexe, propre à l'usage de Claude Code (pas à ASPIC)
En discutant de ces 20€, Jean-Marc a réalisé que **cette conversation même** tourne sur la même clé/le même forfait que l'application — et qu'elle coûte, pour la même raison structurelle (API sans état, tout l'historique renvoyé à chaque tour) : plus un fil de conversation est long, plus chaque réponse coûte cher, indépendamment du sujet traité. Un seul appel de recherche web lancé ce soir a consommé 436 549 tokens à lui seul. Conclusion retenue par Jean-Marc : fermer une session Claude Code dès qu'un sujet aboutit plutôt que de laisser un fil grossir indéfiniment — voir la nouvelle consigne ajoutée à l'Index.
## Addendum (11 septembre 2026) — câblage d'`AGN_Competences`
Point 3 de la feuille de route "BackOffice", laissé ouvert plus haut, traité en addendum le même jour : `agents_competences.php` recalcule désormais intégralement `ASP_Agents.AGN_Competences` (écrasement complet, jamais un ajout incrémental comme pour `AGN_Experience`, puisqu'il doit refléter l'attribution actuelle des compétences, pas un historique) à chaque attribution (POST) et chaque retrait (DELETE) d'une compétence à un collaborateur — nouvelle fonction locale `recalculerCompetencesAgent()`, format retenu après question posée à Jean-Marc : une ligne à puce par compétence (`- Libellé : Description`), triées par libellé.
Champ ensuite branché en lecture, comme prévu dès l'origine, dans `mission_agent.php` et `collaborateur_dialogue.php` : ajouté à la sélection SQL de l'agent et injecté dans le prompt système sous un nouveau bloc "Tes compétences déclarées", juste après le bloc expérience — même traitement qu'`AGN_Identite`/`AGN_Experience`. Non vérifié en conditions réelles à ce stade (à tester via `bo_competences.html` puis un dialogue de test).
## Statut
Les trois points de la feuille de route "BackOffice" sont désormais livrés (points 1 et 2 le 11 septembre après-midi, point 3 — `AGN_Competences` — en addendum le même jour, voir ci-dessus). Le point sur le cache/coût IA, non prévu au départ, a été traité en cours de session à la demande de Jean-Marc.
`<code>` avance (coquille BO complète, deux nouvelles paires endpoint/écran, mécanisme d'espace disque, cache + journalisation IA) ; `<conception>` avance également (nouvelle section 23 des Réflexions stratégiques, voir ce document) — version **V1.22.23**. L'addendum `AGN_Competences` ci-dessus n'a pas fait avancer `<code>` une seconde fois à ce stade (travail pas encore committé/poussé, voir journal Index) — à trancher avec Jean-Marc au moment de la livraison.
@@ -0,0 +1,72 @@
# 33. Vision des compétences, champ Relationnel et sortie des prompts IA du code (11 septembre 2026)
**Origine.** Reprise de session sur le BackOffice Collaborateurs (section 32) : Jean-Marc voulait d'abord une simple vue des compétences déjà attribuées, avant de tomber sur un effet de bord révélateur en testant.
## Point 1 — Vision des compétences dans `bo_collaborateurs.html`
Nouvel onglet **Compétences**, en lecture seule (l'attribution/le retrait restent dans `bo_competences.html`, aucune duplication de CRUD) : interroge `agents_competences.php` (déjà existant) et filtre côté client sur `AGC_AgentIncr` du collaborateur sélectionné. Lien "Gérer les compétences →" vers `bo_competences.html`. Aucun endpoint touché.
## Point 2 — Bug de récitation découvert au premier essai réel
En testant, Jean-Marc avait attribué par jeu la compétence "savoir faire un bon café" à Sophia (aux côtés de compétences réelles) — dans "Tester", elle l'a citée telle quelle, en la commentant, comme n'importe quelle autre compétence. Diagnostic : `$blocCompetences` dans `collaborateur_dialogue.php` est injecté sous un intitulé factuel ("Tes compétences déclarées :") sans jamais porter la consigne anti-récitation qui protège pourtant l'identité et l'expérience — contrairement à `AGN_Identite`/`AGN_Experience`, le catalogue de compétences (`ASP_Competences`) est pensé dès l'origine comme mécanique (voir section 22 des Réflexions stratégiques et l'en-tête de `competences.php`), pas comme un texte à faire vivre dans une conversation.
**Corrigé** : `$blocCompetences` porte désormais sa propre garde, autonome de la consigne générale du prompt ("un aide-mémoire pour savoir ce que tu peux proposer, jamais une liste à réciter, énumérer ou commenter telle quelle... une entrée isolée ou insolite ne mérite pas plus d'attention qu'une autre").
## Point 3 — Clarification conceptuelle : quatre facettes, pas trois
Discussion engagée par Jean-Marc pour clarifier ce que chaque champ représente réellement :
1. **Identité** (`AGN_Identite`) : caractère, goûts — ce que la personne *est*.
2. **Compétences** (`ASP_Competences`/`AGN_Competences`) : ce qu'ASPIC lui apporte comme capacité réelle — mécanique, jamais un CV ("même si personne n'arrive dans une boîte sans rien savoir faire, on n'est pas là pour faire le CV des collaborateurs !").
3. **Expérience** (`AGN_Experience`) : ce qui s'accumule par append au fil des dialogues réels (déjà posé section 20/22).
Point resté sans case évidente pour Jean-Marc : l'aspect **relationnel** envers l'équipe, qu'il avait jusqu'ici mélangé dans l'identité ("je ne sais pas où caser l'aspect relationnel avec l'équipe qui donne selon moi du sens à l'ensemble"). Clarification retenue : une posture relationnelle *initiale* (directe, réservée, chaleureuse...) reste un trait de caractère au même titre que les autres — mais la relation *vécue*, elle, se construit avec le temps et ressemble par nature à ce que l'Expérience capte déjà. Plutôt que de forcer ça dans l'un des trois champs existants, Jean-Marc a choisi une quatrième facette dédiée, statique pour l'instant.
## Point 4 — Nouveau champ `AGN_Relationnel`
Colonne ajoutée par Jean-Marc lui-même (type non spécifié par Claude cette fois — à vérifier qu'elle est bien assez large pour un texte libre, cohérente avec `AGN_Identite`/`AGN_Experience`).
- **Saisie** (`bo_collaborateurs.html`) : nouvel onglet "Relationnel", réutilisant le même panneau/textarea qu'Identité/Expérience — `chargerChamp()` généralisé (table `PANNEAU_PAR_CHAMP` au lieu d'un ternaire à deux branches) plutôt que dupliqué.
- **Lecture/écriture** (`collaborateurs_identite.php`) : `AGN_Relationnel` ajouté au `SELECT` et à la whitelist du `PUT`.
- **Mémoire du collaborateur** (`collaborateur_dialogue.php`) : nouveau `$blocRelationnel`, inséré juste après l'identité — sous la même consigne générale ("aide-mémoire, jamais à réciter") que l'identité, puisque c'est un texte rédigé par Jean-Marc et non une liste factuelle (donc pas de garde dédiée comme pour les compétences).
## Point 5 — Sortir les prompts IA du code
Demande explicite de Jean-Marc ("j'aimerai bien que ces prompts ne soient plus dans le code mais me soient accessibles"), étendue par lui à l'ensemble du projet plutôt qu'au seul dialogue collaborateur. Recensement des quatre prompts système réels du projet :
- `COLLABORATEUR_DIALOGUE` (`collaborateur_dialogue.php`)
- `MISSION_AGENT` (`mission_agent.php`)
- `CONTACT_IA_INTERPRETE` (`contact_ia_interprete.php`)
- `CONTACT_IA_CODE_POSTAL` (`consignes_fonctions.php`, `consigne_completer_code_postal()`)
**Nouvelle table `ASP_Prompts`** (`PMT_Code` varchar en clé primaire, `PMT_Libelle`, `PMT_Texte` mediumtext, `PMT_DateModification`).
**Nouveau `moteur_prompts.php`**, même esprit que `moteur_consignes.php` : `PROMPTS_PAR_DEFAUT` porte le texte d'origine de chaque prompt (placeholders `{{...}}`, jamais perdu) ; `chargerPromptSysteme($pdo, $code, $remplacements)` lit la base et **auto-amorce** la ligne avec son texte par défaut au tout premier appel réel qui en a besoin (même principe qu'`EBAUCHES_IDENTITE`) ; `amorcerPromptsManquants($pdo)` fait de même pour que le BO liste toujours les quatre prompts, même avant tout premier essai réel. Aucun `INSERT` à taper à la main — seule la structure de table reste à créer par Jean-Marc.
Les quatre fichiers appelants remplacent leur heredoc `$promptSysteme` par un appel à `chargerPromptSysteme()`, avec le calcul des blocs variables inchangé. Cas particulier de `consigne_completer_code_postal()` : cette fonction ne reçoit pas `$pdo` par contrat (Traitement à plusieurs champs, voir `moteur_consignes.php`) — plutôt que d'étendre ce contrat une troisième fois pour un seul besoin, elle ouvre sa propre connexion via `getDbConnection()` (déjà globalement disponible, `db.php` étant requis avant `consignes_fonctions.php` par `contacts.php`). Un échec de chargement du prompt y est traité comme la clé API absente : aucun appel réseau n'a encore été tenté, donc seul l'avertissement écran est posé, pas de `_realisation`.
**Nouvelle page BO** `bo_prompts.html` + endpoint `prompts.php` (GET liste, PUT édite le texte d'un code existant — jamais de création libre d'un nouveau code depuis le BO, contrairement au catalogue de compétences, puisqu'un `PMT_Code` est en dur dans le seul fichier PHP qui l'appelle). Ajoutée au menu du BO (`backoffice.js`).
**Incident de livraison du CREATE TABLE.** Jean-Marc n'arrivait pas à copier le texte SQL depuis le chat. Claude a d'abord tenté de créer la table lui-même en profitant de l'accès direct au serveur (conteneur `mysql-mysql-1`, trouvé via `docker ps` sur le réseau `proxy` partagé) — bloqué par le classificateur de sécurité du mode automatique de Claude Code ("Credential Materialization", refus d'exécuter une commande exposant un mot de passe en clair). Pas de contournement forcé : fichier `.sql` envoyé directement via l'outil d'envoi de fichier. Jean-Marc a ensuite trouvé comment copier-coller depuis son propre client et a exécuté le `CREATE TABLE` lui-même — table créée, testée en conditions réelles, fonctionnelle.
## Point 6 — Le prénom de Jean-Marc en dur dans le code
En rédigeant le relationnel de Sophia, Jean-Marc a réalisé un point de fond : *"il n'est pas bon que mon prénom apparaisse dans le code si demain l'application est fournie à Joseph ou à Sylvie !"* — lien direct avec le troisième objectif fondateur d'ASPIC (vente SaaS, voir Réflexions stratégiques section 23).
Recensement de toutes les occurrences de "Jean-Marc" réellement envoyées au modèle ou journalisées (les commentaires de code, jamais lus par personne d'autre que Claude, ne sont pas concernés) : les deux templates de prompts (`COLLABORATEUR_DIALOGUE`, `MISSION_AGENT`), les blocs Relationnel/Expérience construits directement en PHP, et le tag `[Jean-Marc]` posé dans le journal texte d'une mission (`mission_agent.php`). Toutes remplacées par un placeholder `{{PATRON}}`/une variable `$patron`, dérivée de `$_SESSION['USR_Prenom']` (déjà posé en session par `aspic.php` au login), avec repli sur "l'utilisateur" si absent — jamais une nouvelle donnée à saisir, le prénom du patron est déjà celui de la personne connectée.
## Point 7 — Couleur des collaborateurs dans le chat de test
Jean-Marc a signalé que les collaborateurs avaient "perdu leur couleur" dans le panneau "Tester" de `bo_collaborateurs.html`. Vérification faite : `AGN_Couleur` n'était en réalité affichée **nulle part** dans l'application avant ce soir (déjà repéré comme "inutilisée par aucun écran" dans le Reste à faire du 9-10 septembre) — donc pas perdue, plutôt jamais branchée. Corrigé : la bulle du collaborateur reprend désormais sa couleur pastel réelle (`AGN_Couleur`), la bulle de l'utilisateur reprend le jaune classique du post-it humain (`#fbe14a`, même valeur que sur le tableau de liège d'`ecran_fond.html`).
## Test réel et retour de Jean-Marc
Après avoir retiré "café" du catalogue de Sophia et rédigé un premier texte de Relationnel mentionnant "le patron", Jean-Marc a observé qu'elle a cessé de dire "salut !" — enregistré comme un effet attendu du mot choisi (registre plus formel), pas un bug, le modèle restant libre d'ajuster son ton comme convenu depuis la section 20. Affinage individuel de l'identité/du relationnel de chaque collaborateur explicitement reporté par Jean-Marc à plus tard ("quand je n'aurai que ça à faire").
## Environnement de travail : changement de fond, pas propre à ASPIC
Confirmé au fil de cette session : Claude Code tourne désormais **directement sur le serveur** (accès fichier direct à `/docker/aspic`, aux conteneurs Docker via `docker ps`/`docker exec`) — fini l'environnement cloud éphémère décrit dans la consigne du 2 septembre 2026 (voir Index). Le circuit FileZilla → SSH → `git push` reste la méthode de Jean-Marc pour livrer du code (il garde volontairement la main sur le commit et son message, "ça m'oblige aussi à continuer à manipuler"), mais n'est plus indispensable pour qu'un fichier existant devienne visible/modifiable par Claude. Jean-Marc envisage de créer un répertoire dédié pour transmettre directement des fichiers (image notamment) — pas fait ce soir, faute d'avoir retrouvé l'image en question.
## Statut
Version incrémentée une fois pour l'ensemble de cette session, sur les deux compteurs à la demande explicite de Jean-Marc : **V1.23.24**. Nouvelle section 24 des Réflexions stratégiques ouverte pour la partie conceptuelle (voir ce document).
Au passage, correction actée avec Jean-Marc et reportée dans l'Index : la consigne du 6 septembre 2026 sur `<conception>` ("reprend le numéro de la dernière section créée") était fausse depuis le début, jamais une règle réelle — un simple compteur libre, incrémenté à chaque évolution jugée majeure, sans lien garanti avec le numéro que prendra la section correspondante dans les Réflexions stratégiques.
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* ASPIC — journal_ia.php
*
* Journalisation du coût réel des appels à l'API Anthropic (11 septembre
* 2026) — demande explicite de Jean-Marc après avoir constaté plus de 20€
* de consommation sans pouvoir se l'expliquer : ni collaborateur_dialogue.php
* ni mission_agent.php ne traçaient jusqu'ici le champ `usage` pourtant
* renvoyé par l'API à chaque appel. Même préoccupation que celle qui avait
* mené à `_realisation`/`_avertissement` dans moteur_consignes.php (7
* septembre 2026) : ne jamais laisser un appel IA sans trace.
*
* Tarifs (USD par million de tokens, vérifiés le 8 septembre 2026 — voir
* Réflexions stratégiques section 18 — susceptibles d'évoluer, à recontrôler
* si un écart est constaté). Cache : ratios documentés par Anthropic au
* moment de l'écriture de ce fichier (écriture ×1,25, lecture ×0,1 du tarif
* d'entrée de base) — comme le reste de ce mécanisme, jamais essayé en
* conditions réelles avant ce soir (voir en-tête de mission_agent.php pour
* le même type de réserve). Coût exprimé en dollars US (devise de
* facturation réelle d'Anthropic), jamais converti en euros ici — une
* conversion afficherait une fausse précision sans connaître le taux de
* change réel appliqué par Anthropic au moment du prélèvement.
*/
const TARIFS_IA = [
'claude-haiku-4-5' => ['entree' => 1.0, 'sortie' => 5.0],
'claude-sonnet-5' => ['entree' => 2.0, 'sortie' => 10.0],
'claude-opus-5' => ['entree' => 5.0, 'sortie' => 25.0],
'claude-fable-5-1' => ['entree' => 10.0, 'sortie' => 50.0],
];
/**
* Enregistre un appel réel à l'API Anthropic. $usage est le champ "usage"
* tel que renvoyé par l'API (input_tokens, output_tokens,
* cache_creation_input_tokens, cache_read_input_tokens — ces deux derniers
* absents/à 0 si le cache n'a pas été sollicité ce tour-ci). Jamais
* bloquant : une erreur ici ne doit jamais faire échouer le dialogue
* lui-même, seulement être signalée dans les journaux serveur.
*/
function journaliserAppelIA(PDO $pdo, string $origine, ?int $agentIncr, ?int $missionIncr, string $modele, array $usage): void
{
$entreeFrais = (int) ($usage['input_tokens'] ?? 0);
$entreeCache = (int) ($usage['cache_read_input_tokens'] ?? 0);
$ecritureCache = (int) ($usage['cache_creation_input_tokens'] ?? 0);
$sortie = (int) ($usage['output_tokens'] ?? 0);
$tarif = TARIFS_IA[$modele] ?? TARIFS_IA['claude-sonnet-5'];
$cout = (
$entreeFrais / 1_000_000 * $tarif['entree'] +
$ecritureCache / 1_000_000 * $tarif['entree'] * 1.25 +
$entreeCache / 1_000_000 * $tarif['entree'] * 0.1 +
$sortie / 1_000_000 * $tarif['sortie']
);
try {
$pdo->prepare(
'INSERT INTO ASP_AppelsIA
(AIA_Origine, AIA_AgentIncr, AIA_MissionIncr, AIA_Modele,
AIA_TokensEntreeFrais, AIA_TokensEntreeCache, AIA_TokensEcritureCache, AIA_TokensSortie,
AIA_CoutEstimeUsd)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
)->execute([$origine, $agentIncr, $missionIncr, $modele, $entreeFrais, $entreeCache, $ecritureCache, $sortie, $cout]);
} catch (PDOException $e) {
error_log('ASPIC journal_ia : échec de journalisation — ' . $e->getMessage());
}
}
+498 -173
View File
@@ -2,7 +2,10 @@
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>ASPIC — Liste des contacts</title>
<title>ASPIC. Classeur des contacts</title>
<!-- Icône d'onglet (3 septembre 2026, voir journal) : logo Mévélia Solutions,
fourni par Jean-Marc, reprise à l'identique sur tous les écrans. -->
<link rel="icon" href="mevelia-icone.png">
<style>
@font-face{
font-family:"Metro DF";
@@ -59,87 +62,82 @@
padding:0 16px;
gap:14px;
}
#hamburger{
font-size:20px;cursor:pointer;color:var(--texte-bandeau);
padding:2px 6px;border-radius:4px;
display:flex;align-items:center;
}
#hamburger:hover{background:rgba(0,0,0,0.08);}
#logo-aspic{
display:flex;align-items:center;
background:var(--cartouche-bg);
border-radius:5px;
padding:4px 10px;
font-family:var(--font-logo);
font-weight:900;font-size:15px;letter-spacing:1.5px;
}
#logo-aspic .A{color:var(--orange);}
#logo-aspic .rest{color:#FFFFFF;}
/* Icône hamburger et logo ASPIC du bandeau haut supprimés le 3 septembre
2026 (voir journal) : le menu hamburger n'a plus de raison d'être
maintenant que l'écran de fond (ecran_fond.html) est le véritable écran
d'accueil et qu'Échap y ramène directement depuis cet écran ; le logo
ASPIC est réduit et déplacé dans la barre de statut, devant le numéro de
version (voir #logo-aspic-mini plus bas). #titre-ecran prend la place
laissée par l'icône hamburger, en tout premier dans le bandeau. */
#titre-ecran{
font-size:16px;font-weight:800;color:var(--texte-bandeau);letter-spacing:0.5px;
}
#bandeau-spacer{flex:1;}
#avatar{
width:28px;height:28px;border-radius:50%;
background:var(--bleu-badge);color:#FFFFFF;
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:10px;cursor:default;
border:1px solid rgba(0,0,0,0.2);
margin-left:6px;
}
/* Badge société (29 août 2026) : repère visuel d'environnement, à gauche
de l'avatar. "Bouton" non cliquable (cursor:default, pas de hover) —
forme pilule plutôt que le disque de l'avatar, pour bien distinguer les
deux informations (société vs utilisateur) tout en les regroupant
visuellement. Masqué (display:none) si USR_Societe est vide en base
pour l'utilisateur connecté — voir chargerPreferences(). */
#badge-societe{
height:20px;padding:0 10px;border-radius:10px;
background:var(--bleu-badge);color:#FFFFFF;
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:10px;letter-spacing:0.3px;cursor:default;
border:1px solid rgba(0,0,0,0.2);
/* Avatar (nom court de l'utilisateur) et badge société supprimés d'ici le
3 septembre 2026 (voir journal) : le bandeau haut ne porte plus que le
titre. Société et prénom sont désormais affichés en texte simple dans
la barre de statut basse (#societe-utilisateur, #prenom-utilisateur),
par cohérence avec le chevalet de l'écran de fond qui affiche déjà
USR_Prenom sans cartouche. */
/* Société et prénom de l'utilisateur connecté (3 septembre 2026, voir
journal) : dans la barre de statut basse, la société juste devant le
prénom, lui-même juste devant le logo ASPIC miniature — voir
#statut-version-bloc. Même mise en forme pour les deux (texte simple,
plus de cartouche bleu comme l'ancien badge société). */
#societe-utilisateur, #prenom-utilisateur{
font-size:11px;font-weight:700;color:#2a2a2a;
white-space:nowrap;
}
#badge-societe[hidden]{display:none;}
#societe-utilisateur[hidden]{display:none;}
/* -------- Bouton micro : pilotage vocal (aspect graphique, 1er septembre
2026 — la reconnaissance vocale elle-même n'est pas encore branchée à ce
stade, seul le rond cliquable et son animation d'écoute le sont).
Placé après l'avatar (plutôt qu'avant le badge société) pour rester à
une position fixe dans le bandeau : contrairement au badge société,
toujours visible, il ne doit pas se déplacer selon que l'utilisateur
connecté a ou non une société renseignée. */
Déplacé du bandeau haut vers la barre de statut basse le 3 septembre
2026 (voir journal), puis recentré au milieu de cette barre le même
jour — cohérence demandée avec l'écran de fond, où le micro est posé au
milieu du bureau. Repris de l'écran de fond également : mêmes icônes
SVG (glyphe repos/écoute, voir ecran_fond.html #micro-icone) et mêmes
couleurs (gris clair au repos, rouge en écoute) — seul micro.js (le
comportement réel, partagé entre écrans) reste inchangé : il ne fait
que basculer la classe "ecoute" sur #bouton-micro, geste indépendant de
l'habillage. Rond noir ajouté le 3 septembre 2026 (retour de Jean-Marc :
l'icône gris clair sur le fond gris clair de la barre était quasiment
invisible — sur l'écran de fond elle se détachait sur le noir du micro
photographié, contraste absent ici) — même teinte que le logo ASPIC
miniature (--cartouche-bg) pour rester dans le même langage visuel. */
#bouton-micro{
width:28px;height:28px;border-radius:50%;
background:var(--rouge-micro);
position:absolute;left:50%;top:50%;
transform:translate(-50%,-50%);
width:24px;height:24px;
border:none;background:var(--cartouche-bg);border-radius:50%;padding:0;margin:0;
display:flex;align-items:center;justify-content:center;
cursor:pointer;border:1px solid rgba(0,0,0,0.2);
flex:0 0 auto;
cursor:pointer;
}
#bouton-micro:hover{filter:brightness(1.08);}
#bouton-micro:active{filter:brightness(0.94);}
/* État repos : icône micro simple. */
#icone-micro{display:block;}
#icone-micro, #barres-ecoute{width:70%;height:70%;display:block;}
/* État repos : icône micro simple, gris clair (identique à l'écran de
fond). */
#icone-micro{fill:#d9d9d3;opacity:.92;}
#bouton-micro.ecoute #icone-micro{display:none;}
/* État écoute : petites barres verticales animées (façon égaliseur),
substituées à l'icône micro tant que l'écoute est active. */
#barres-ecoute{
display:none;align-items:flex-end;justify-content:center;
gap:2px;height:14px;
/* État écoute : barres façon vu-mètre, rouge, animées — masquées par
défaut (identique à l'écran de fond, sélecteur svg[hidden] repris tel
quel). */
#barres-ecoute{display:none;fill:#e14a5c;}
#barres-ecoute[hidden]{display:none;}
#bouton-micro.ecoute #barres-ecoute{display:block;}
#barres-ecoute .barre{
transform-box:fill-box;
transform-origin:50% 100%;
animation:pulse-barre 1s ease-in-out infinite;
}
#bouton-micro.ecoute #barres-ecoute{display:flex;}
#barres-ecoute span{
width:3px;background:#FFFFFF;border-radius:2px;
animation:onde-vocale 0.9s ease-in-out infinite;
}
#barres-ecoute span:nth-child(1){height:6px;animation-delay:0s;}
#barres-ecoute span:nth-child(2){height:14px;animation-delay:0.15s;}
#barres-ecoute span:nth-child(3){height:9px;animation-delay:0.3s;}
#barres-ecoute span:nth-child(4){height:12px;animation-delay:0.45s;}
@keyframes onde-vocale{
0%,100%{transform:scaleY(0.4);}
50%{transform:scaleY(1);}
#barres-ecoute .barre:nth-child(1){ animation-delay:-.6s; }
#barres-ecoute .barre:nth-child(2){ animation-delay:-.3s; }
#barres-ecoute .barre:nth-child(3){ animation-delay:0s; }
#barres-ecoute .barre:nth-child(4){ animation-delay:-.45s; }
@keyframes pulse-barre{
0%,100%{ transform:scaleY(.55); }
50%{ transform:scaleY(1); }
}
/* -------- En-tête colonnes -------- */
@@ -208,8 +206,12 @@
.ligne-contact .col{font-size:11px;}
/* -------- Barre de statut -------- */
/* Hauteur portée de 20 à 32px le 3 septembre 2026 (voir journal) : le
bouton micro (déplacé ici depuis le bandeau haut, voir plus bas)
n'y tenait pas sans être rogné. Reste centré verticalement comme les
autres éléments de la barre via align-items:center ci-dessous. */
#statut{
height:20px;width:100%;flex:0 0 auto;
height:32px;width:100%;flex:0 0 auto;
background:var(--statut-bg);
border-top:1px solid #a0a0a0;
display:flex;align-items:center;
@@ -218,23 +220,73 @@
gap:24px;
position:relative;
}
/* Numéro de version (28 août 2026, repositionné le même jour x2 à la
demande de Jean-Marc : d'abord "à fond à droite" — le margin-left:auto
précédent laissait en fait de la place après lui pour le repère
d'ascenseur, ce qui ne faisait pas assez "collé au bord" à l'usage —
puis rendu plus lisible, la première teinte grise étant trop proche du
fond de la barre pour être vue sans effort). Sorti du flux flex et
calé en absolu contre le bord droit réel de la barre, juste avant le
repère d'ascenseur (--sb-width, déjà utilisé plus haut pour ce même
repère). Couleur assombrie et graisse ajoutée pour un contraste correct
sur le fond clair de la barre, tout en restant plus sobre que le texte
fonctionnel (compte/tri/filtre). Propre à cet environnement déployé —
voir version.php. */
#statut-version{
/* Bloc société + prénom + marque (28 août 2026, numéro de version seul à
l'origine ; complété le 3 septembre 2026 — voir journal). Sorti du flux
flex et calé en absolu contre le bord droit réel de la barre — "à fond
à droite" plutôt qu'un margin-left:auto, qui laissait de la place après
lui. Regroupe désormais, de gauche à droite : la société de
l'utilisateur connecté (ex-badge du bandeau haut, même donnée mais sans
cartouche), un point séparateur, son prénom, un second point
séparateur, puis #marque-aspic (icône Mévélia Solutions + logo ASPIC
miniature + numéro de version, voir plus bas) — un seul conteneur flex
pour ne jamais avoir à recalculer une largeur fixe quand l'un de ces
éléments change de taille. Le bouton micro n'en fait plus partie depuis
le 3 septembre 2026 : recentré au milieu de toute la barre, voir
#bouton-micro plus bas, par cohérence avec l'écran de fond.
right:16px fixé en dur (et non plus calc(var(--sb-width)+10px)) depuis
le 3 septembre 2026 : --sb-width mesure la largeur de l'ascenseur
interne de #zone-liste, sans rapport avec cette barre du bas qui n'a
pas d'ascenseur propre — ce calc introduisait un décalage horizontal
variable et rendait ce bloc mal aligné avec le même bloc, positionné en
fixed;right:16px, sur l'écran de fond. Jean-Marc l'a repéré à l'usage
("la ligne du bas... se décale vers la droite"). Valeur désormais
identique aux deux écrans, pour un alignement pixel près. */
#statut-version-bloc{
position:absolute;
right:calc(var(--sb-width) + 10px);
right:16px;
top:50%;
transform:translateY(-50%);
display:flex;align-items:center;gap:8px;
}
/* Points séparateurs (3 septembre 2026, voir journal) : entre société et
prénom, puis entre prénom et #marque-aspic — "gros point" demandé par
Jean-Marc, plus grand que le texte environnant (11px) pour bien
marquer la séparation. #point-societe masqué en même temps que
#societe-utilisateur (voir chargerPreferences()) pour ne jamais laisser
un point orphelin quand l'utilisateur connecté n'a pas de société. */
.point-separateur{
font-size:14px;line-height:1;color:#2a2a2a;
}
/* Marque ASPIC (3 septembre 2026, voir journal) : icône Mévélia Solutions
(fournie par Jean-Marc) + logo ASPIC miniature + numéro de version,
groupés visuellement comme un seul bloc de marque — d'où un espacement
interne plus serré (4px) que le reste de la barre, moitié du 8px
d'origine entre logo et version, à la demande de Jean-Marc. Porte
l'infobulle "ASPIC Vx.xxx / Créé par Mevelia Solutions" sur l'ensemble
du groupe (title posé ici en HTML avec la version à blanc, complété par
chargerVersion() une fois le numéro réel connu — voir script). */
#marque-aspic{
display:flex;align-items:center;gap:4px;
}
/* Rond plutôt que carré (3 septembre 2026, retour de Jean-Marc) — image
source encore carrée à ce jour, simplement recadrée en cercle ici ;
Jean-Marc prévoit de retravailler l'image elle-même de son côté. */
#icone-mevelia{
width:16px;height:16px;
border-radius:50%;
display:block;
}
#logo-aspic-mini{
display:flex;align-items:center;
background:var(--cartouche-bg);
border-radius:4px;
padding:2px 6px;
font-family:var(--font-logo);
font-weight:900;font-size:9px;letter-spacing:1px;
}
#logo-aspic-mini .A{color:var(--orange);}
#logo-aspic-mini .rest{color:#FFFFFF;}
#statut-version{
color:#4a4a4a;
font-weight:700;
font-size:11px;
@@ -288,15 +340,6 @@
#menu-detail-contextuel .item.item-a-venir{font-style:italic;}
#menu-contextuel .separateur, #menu-detail-contextuel .separateur{height:1px;background:#e0e0e0;}
/* -------- Menu hamburger -------- */
#menu-hamburger{
position:absolute;top:44px;left:16px;display:none;
min-width:240px;z-index:500;
}
#menu-hamburger .item{padding:12px 18px;font-size:14px;color:#1e1e1e;cursor:pointer;}
#menu-hamburger .item:hover{background:var(--gris-clair);}
#menu-hamburger .item.desactive{color:#aaaaaa;cursor:not-allowed;}
/* -------- Panneau filtre -------- */
#voile{
position:fixed;top:0;left:0;width:100vw;height:100vh;
@@ -632,7 +675,12 @@
.ligne-realisation.zebra-b{background:var(--zebra-bleu);}
.ligne-realisation .col-rea{padding:0 8px 0 0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
.col-rea-date{width:120px;flex:0 0 auto;}
.col-rea-qui{width:40px;flex:0 0 auto;}
/* 40px -> 90px (7 septembre 2026, nuit) : dimensionnée à l'origine pour un
code à 3 caractères (REA_CodeUser, ex. "JMP"), devenue trop étroite
depuis l'affichage de REA_NomAffiche (prénom en clair, ex. "Jean-Marc",
"Matilda") — le texte était tronqué à 2-3 caractères par l'ellipsis
ci-dessus (Jean-Marc : "n'affiche que 3 caractères"). */
.col-rea-qui{width:90px;flex:0 0 auto;}
.col-rea-action{width:190px;flex:0 0 auto;}
.col-rea-comment{flex:1 1 auto;}
#pied-realisations{
@@ -651,36 +699,86 @@
padding:14px 24px;border-radius:6px;font-size:14px;
box-shadow:0 6px 20px rgba(0,0,0,0.3);
display:none;z-index:700;
/* Un CNS_MessageErreur (ou tout autre texte affiché ici) peut contenir
des retours à la ligne saisis en base — pre-line les restitue tels
quels au lieu de les fondre en un seul espace (comportement HTML par
défaut), tout en continuant à retourner à la ligne normalement si le
texte est trop long. Concerne tous les toasts, pas seulement l'erreur :
aucune raison qu'un message informatif se comporte différemment. */
white-space:pre-line;max-width:420px;text-align:center;
}
/* Variante erreur (6 septembre 2026, chantier des consignes) : un échec de
consigne au moment d'enregistrer une fiche contact doit sauter aux yeux,
centré sur le panneau de saisie plutôt qu'en bas de l'écran — voir
afficherToast(message, erreur) et validerFormulaireContact(). */
#toast.toast-erreur{
top:50%;bottom:auto;transform:translate(-50%,-50%);
background:var(--rouge-suppression);color:#fff;
}
/* -------- Badge IA générique (7 septembre 2026, soir) --------
Affiché en plein milieu de l'écran, "comme s'il s'agissait d'un message"
(demande explicite de Jean-Marc) — même position que #toast.toast-erreur
ci-dessus, même z-index (jamais affiché en même temps qu'un toast dans la
pratique : masqué avant qu'un toast d'erreur éventuel n'apparaisse, voir
validerFormulaireContact()). Volontairement générique, pas un habillage
par agent/couleur (Jean-Marc a explicitement écarté cette idée : "si un
jour un traitement fait appel à plusieurs agents à la suite, il ne
faudrait pas que l'écran se transforme en sapin de Noël").
Affiché UNIQUEMENT quand consignes_apercu.php répond que l'IA va
réellement être sollicitée (voir validerFormulaireContact() plus bas,
et moteur_consignes.php pour le mécanisme "_seraitDeclenchee") — jamais
de façon systématique à chaque enregistrement, ni pour un simple
enregistrement classique où le temps de traitement est imperceptible :
décision ferme de Jean-Marc ("l'utilisateur pourrait s'affoler sur le
coût systématique d'une insertion client"). Taille : 65px (7 septembre
2026, nuit — plusieurs essais avec Jean-Marc, 130px puis 50px puis 25px
jugés tour à tour trop gros, avant un retour à 65px une fois un souci de
rafraîchissement d'écran écarté), confortablement au-dessus de la
quarantaine de pixels repérée comme seuil de lisibilité du dessin
(trois volutes fines) lors du tout premier test Playwright. Simple
valeur CSS, aucune autre incidence. pointer-events:none (même principe
que #micro-icone/#pastille-contacts sur l'écran de fond) : ce badge est
purement informatif, jamais un obstacle au clic. */
#badge-ia-actif{
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
width:65px;height:65px;
display:none;z-index:700;
pointer-events:none;
}
#badge-ia-actif svg{display:block;width:100%;height:100%;}
/* Ligne "nom de l'agent" sous le message (7 septembre 2026) : seulement
pour le toast d'erreur, seulement quand la consigne en échec a un
CNS_Agent renseigné — voir afficherToast(message, erreur, agent) et
moteur_consignes.php. Italique pour la distinguer visuellement du
message lui-même, légèrement moins contrastée (mais toujours lisible
sur le fond rouge) plutôt qu'une simple répétition du même style. */
#toast .toast-agent{
margin-top:6px;font-style:italic;color:rgba(255,255,255,0.85);
}
/* Toast bloquant (7 septembre 2026, avertissement IA) : ce toast-là ne
disparaît pas tout seul (voir afficherToast()) — il faut une action
explicite de l'utilisateur pour le refermer, ce qui fait alors repasser
la fiche du mode édition au mode vue (voir validerFormulaireContact()).
Portait initialement un bouton "Valider" cliquable (règle CSS
#toast .toast-bouton, retirée le 8 septembre 2026) — remplacé par Échap
seul (Jean-Marc : le bouton était accessible, mais Échap, plus naturel
ici, refermait AUSSI le panneau détail en dessous au lieu du seul toast).
Voir le gestionnaire keydown global (toastValidationEnCours, vérifié en
tout premier, avant le menu contextuel du panneau détail) et
fermerToastValidation(). */
</style>
</head>
<body>
<div id="app">
<!-- Bandeau applicatif -->
<!-- Avatar (nom court), badge société et bouton micro retirés d'ici le
3 septembre 2026 (voir journal) : le bandeau haut ne porte plus que le
titre — société, prénom, micro et logo sont désormais regroupés dans
la barre de statut basse (voir #statut-version-bloc plus bas). -->
<div id="bandeau">
<div id="hamburger" title="Menu">&#9776;</div>
<div id="logo-aspic"><span class="A">A</span><span class="rest">SPIC</span></div>
<div id="titre-ecran">LISTE DES CONTACTS</div>
<div id="titre-ecran">CLASSEUR DES CONTACTS. Liste de synthèse</div>
<div id="bandeau-spacer"></div>
<div id="badge-societe" title="Société" hidden></div>
<div id="avatar" title="Utilisateur"></div>
<div id="bouton-micro" title="Le micro est inactif" role="button" tabindex="0">
<svg id="icone-micro" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3z" fill="#FFFFFF"/>
<path d="M19 11a7 7 0 0 1-14 0" stroke="#FFFFFF" stroke-width="1.6" fill="none" stroke-linecap="round"/>
<path d="M12 18v3M9 21h6" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
<!-- Barre diagonale (micro inactif) : ligne de fond couleur du
bouton pour "découper" le glyphe, puis ligne blanche fine
par-dessus pour un tracé net — même technique que les icônes
"off" usuelles (caméra coupée, notifications coupées...). -->
<line x1="5" y1="5" x2="19" y2="19" stroke="#D9364B" stroke-width="3.2" stroke-linecap="round"/>
<line x1="5" y1="5" x2="19" y2="19" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div id="barres-ecoute" aria-hidden="true">
<span></span><span></span><span></span><span></span>
</div>
</div>
</div>
<!-- En-tête de colonnes -->
@@ -717,7 +815,35 @@
<span id="statut-compte">0/0 contacts</span>
<span id="statut-tri">Tri : Nom ascendant</span>
<span id="statut-filtre">Filtre : aucun</span>
<span id="statut-version"></span>
<!-- Bouton micro (3 septembre 2026, voir journal) : centré au milieu de
toute la barre (position absolue indépendante de #statut-version-
bloc ci-dessous), par cohérence avec l'écran de fond où le micro
est posé au milieu du bureau. Icônes et comportement identiques à
ecran_fond.html #micro-icone (voir commentaire CSS). -->
<button id="bouton-micro" type="button" title="Le micro est inactif">
<svg id="icone-micro" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg id="barres-ecoute" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" hidden aria-hidden="true">
<rect class="barre" x="2" y="14" width="3.5" height="7" rx="1"/>
<rect class="barre" x="8" y="9" width="3.5" height="12" rx="1"/>
<rect class="barre" x="14" y="4" width="3.5" height="17" rx="1"/>
<rect class="barre" x="20" y="11" width="3.5" height="10" rx="1"/>
</svg>
</button>
<div id="statut-version-bloc">
<span id="societe-utilisateur" hidden></span>
<span class="point-separateur" id="point-societe" hidden aria-hidden="true">&#8226;</span>
<span id="prenom-utilisateur"></span>
<span class="point-separateur" aria-hidden="true">&#8226;</span>
<div id="marque-aspic" title="ASPIC
Créé par Mevelia Solutions">
<img id="icone-mevelia" src="mevelia-icone.png" alt="Mévélia Solutions">
<span id="logo-aspic-mini"><span class="A">A</span><span class="rest">SPIC</span></span>
<span id="statut-version"></span>
</div>
</div>
<span class="case-ascenseur"></span>
</div>
@@ -732,15 +858,6 @@
<div class="item" id="menu-filtrer">Filtrer</div>
</div>
<!-- Menu hamburger -->
<div id="menu-hamburger" class="panneau-aspic">
<div class="panneau-titre">Menu principal</div>
<div class="item" id="menu-vers-todolist">ToDoList</div>
<div class="item">Gestion des contacts</div>
<div class="item desactive">Gestion des actions</div>
<div class="item desactive">Documentation</div>
</div>
<!-- Voile générique : sert au panneau filtre ET au panneau détail (un seul
panneau modal ouvert à la fois sur cet écran). -->
<div id="voile"></div>
@@ -983,6 +1100,41 @@
<!-- Toast -->
<div id="toast"></div>
<!-- Badge IA générique (7 septembre 2026, soir — voir commentaire CSS
#badge-ia-actif ci-dessus pour le détail des décisions). Fourni par
Jean-Marc (fichier MevelIA_loader_spin.svg) : pinwheel aux couleurs
ASPIC (blanc cassé + orange de la marque), déjà pensé pour tourner en
CSS pur et respecter prefers-reduced-motion — repris ici tel quel,
sans aucune modification du dessin. -->
<div id="badge-ia-actif" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-108 -108 216 216" width="216" height="216">
<defs>
<radialGradient id="badgeIaGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#101820" stop-opacity="0.55"/>
<stop offset="55%" stop-color="#101820" stop-opacity="0.30"/>
<stop offset="100%" stop-color="#101820" stop-opacity="0"/>
</radialGradient>
<style>
.mevelia-spin {
transform-origin: 0px 0px;
animation: mevelia-rotate 2.6s linear infinite;
}
@keyframes mevelia-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.mevelia-spin { animation: none; }
}
</style>
</defs>
<circle cx="0" cy="0" r="108" fill="url(#badgeIaGlow)"/>
<g class="mevelia-spin">
<g transform="rotate(100)"><path d="M 19.32,-5.18 L 20.10,-4.24 L 20.95,-3.26 L 21.80,-2.20 L 22.64,-1.05 L 23.44,0.17 L 24.20,1.48 L 24.91,2.87 L 25.55,4.35 L 26.11,5.91 L 26.59,7.54 L 26.99,9.25 L 27.28,11.02 L 27.47,12.86 L 27.55,14.75 L 27.51,16.70 L 27.35,18.68 L 27.06,20.70 L 26.63,22.75 L 26.07,24.81 L 25.36,26.88 L 24.51,28.96 L 23.52,31.02 L 22.37,33.06 L 21.07,35.07 L 19.63,37.04 L 18.03,38.96 L 16.29,40.82 L 14.39,42.61 L 12.36,44.31 L 10.18,45.92 L 7.87,47.43 L 5.42,48.82 L 2.84,50.09 L 0.15,51.22 L -2.66,52.21 L -5.58,53.05 L -8.59,53.73 L -11.69,54.24 L -14.88,54.57 L -18.13,54.71 L -21.44,54.67 L -24.80,54.43 L -28.20,53.98 L -31.62,53.33 L -35.06,52.47 L -38.49,51.39 L -41.91,50.09 L -45.30,48.58 L -48.65,46.84 L -51.94,44.89 L -55.17,42.72 L -58.31,40.33 L -61.35,37.72 L -64.29,34.91 L -67.10,31.88 L -69.77,28.66 L -72.28,25.24 L -74.63,21.64 L -76.80,17.85 L -78.78,13.89" fill="none" stroke="#FBF8F4" stroke-width="11.9" stroke-linecap="round" stroke-linejoin="round"/><circle cx="-78.78" cy="13.89" r="11" fill="#FBF8F4"/></g><g transform="rotate(220)"><path d="M 19.32,-5.18 L 20.10,-4.24 L 20.95,-3.26 L 21.80,-2.20 L 22.64,-1.05 L 23.44,0.17 L 24.20,1.48 L 24.91,2.87 L 25.55,4.35 L 26.11,5.91 L 26.59,7.54 L 26.99,9.25 L 27.28,11.02 L 27.47,12.86 L 27.55,14.75 L 27.51,16.70 L 27.35,18.68 L 27.06,20.70 L 26.63,22.75 L 26.07,24.81 L 25.36,26.88 L 24.51,28.96 L 23.52,31.02 L 22.37,33.06 L 21.07,35.07 L 19.63,37.04 L 18.03,38.96 L 16.29,40.82 L 14.39,42.61 L 12.36,44.31 L 10.18,45.92 L 7.87,47.43 L 5.42,48.82 L 2.84,50.09 L 0.15,51.22 L -2.66,52.21 L -5.58,53.05 L -8.59,53.73 L -11.69,54.24 L -14.88,54.57 L -18.13,54.71 L -21.44,54.67 L -24.80,54.43 L -28.20,53.98 L -31.62,53.33 L -35.06,52.47 L -38.49,51.39 L -41.91,50.09 L -45.30,48.58 L -48.65,46.84 L -51.94,44.89 L -55.17,42.72 L -58.31,40.33 L -61.35,37.72 L -64.29,34.91 L -67.10,31.88 L -69.77,28.66 L -72.28,25.24 L -74.63,21.64 L -76.80,17.85 L -78.78,13.89" fill="none" stroke="#FF5A1F" stroke-width="11.9" stroke-linecap="round" stroke-linejoin="round"/><circle cx="-78.78" cy="13.89" r="11" fill="#FF5A1F"/></g><g transform="rotate(340)"><path d="M 19.32,-5.18 L 20.10,-4.24 L 20.95,-3.26 L 21.80,-2.20 L 22.64,-1.05 L 23.44,0.17 L 24.20,1.48 L 24.91,2.87 L 25.55,4.35 L 26.11,5.91 L 26.59,7.54 L 26.99,9.25 L 27.28,11.02 L 27.47,12.86 L 27.55,14.75 L 27.51,16.70 L 27.35,18.68 L 27.06,20.70 L 26.63,22.75 L 26.07,24.81 L 25.36,26.88 L 24.51,28.96 L 23.52,31.02 L 22.37,33.06 L 21.07,35.07 L 19.63,37.04 L 18.03,38.96 L 16.29,40.82 L 14.39,42.61 L 12.36,44.31 L 10.18,45.92 L 7.87,47.43 L 5.42,48.82 L 2.84,50.09 L 0.15,51.22 L -2.66,52.21 L -5.58,53.05 L -8.59,53.73 L -11.69,54.24 L -14.88,54.57 L -18.13,54.71 L -21.44,54.67 L -24.80,54.43 L -28.20,53.98 L -31.62,53.33 L -35.06,52.47 L -38.49,51.39 L -41.91,50.09 L -45.30,48.58 L -48.65,46.84 L -51.94,44.89 L -55.17,42.72 L -58.31,40.33 L -61.35,37.72 L -64.29,34.91 L -67.10,31.88 L -69.77,28.66 L -72.28,25.24 L -74.63,21.64 L -76.80,17.85 L -78.78,13.89" fill="none" stroke="#FBF8F4" stroke-width="11.9" stroke-linecap="round" stroke-linejoin="round"/><circle cx="-78.78" cy="13.89" r="11" fill="#FBF8F4"/></g>
</g>
</svg>
</div>
<script>
/* =========================================================================
ASPIC — Écran "Liste des contacts" + panneau "Détail du contact"
@@ -1216,6 +1368,17 @@ document.querySelectorAll(".col.sortable").forEach(col=>{
// ---- Navigation clavier + Échap ----
document.addEventListener("keydown", (e)=>{
if(e.key === "Escape"){
// Toast bloquant en attente de fermeture (8 septembre 2026), vérifié
// AVANT TOUT LE RESTE — y compris le menu contextuel du panneau détail
// ci-dessous : sans cette priorité, Échap fermait le panneau détail (ou
// le menu) EN PLUS du toast au lieu du toast seul (bug signalé par
// Jean-Marc — le bouton "Valider" du toast a été retiré au profit
// d'Échap seul, voir afficherToast()/fermerToastValidation()).
if(toastValidationEnCours){
e.preventDefault();
fermerToastValidation();
return;
}
// Menu contextuel du panneau détail vérifié EN PREMIER (27 août 2026,
// correction d'un bug signalé par Jean-Marc) : ce menu ne s'ouvre que
// panneau détail déjà ouvert, donc panneauDetailOuvert() est également
@@ -1238,16 +1401,17 @@ document.addEventListener("keydown", (e)=>{
fermerMenuContextuel();
return;
}
if(menuHamburger.style.display === "block"){
e.preventDefault();
fermerMenuHamburger();
return;
}
if(voile.style.display === "block"){
e.preventDefault();
fermerPanneauFiltre();
return;
}
// Aucun panneau superposé ouvert (3 septembre 2026, voir journal) :
// Échap ramène directement à l'écran de fond, devenu le véritable écran
// d'accueil post-identification depuis le 2-3 septembre 2026 — plus
// besoin de passer par le menu hamburger, supprimé de cet écran.
e.preventDefault();
window.location.href = "ecran_fond.html";
return;
}
@@ -1356,12 +1520,63 @@ document.getElementById("menu-filtrer").addEventListener("click", ()=>{
// ---- Toast ----
let toastTimer = null;
function afficherToast(message){
// Rappel à déclencher quand l'utilisateur referme (Échap) un toast bloquant
// actuellement affiché — null tant qu'aucun n'est ouvert (8 septembre 2026,
// voir afficherToast()/fermerToastValidation() et le gestionnaire keydown
// global, qui le vérifie en tout premier).
let toastValidationEnCours = null;
// Troisième paramètre "agent" (7 septembre 2026) : nom libre (CNS_Agent,
// voir moteur_consignes.php) de l'agent rattaché à la consigne en échec,
// remonté par contacts.php dans data.agent — affiché en italique sur une
// ligne sous le message, uniquement pour le toast d'erreur et uniquement
// quand une valeur est effectivement présente (une consigne sans agent
// renseigné ne doit rien ajouter). Reconstruit le contenu du toast en deux
// éléments plutôt qu'un simple textContent : nécessaire pour isoler le
// style italique sur la seule ligne agent, sans toucher au message
// principal ni à son white-space:pre-line hérité (voir CSS #toast).
function afficherToast(message, erreur, agent, onValider){
const toast = document.getElementById("toast");
toast.textContent = message;
toast.innerHTML = "";
const ligneMessage = document.createElement("div");
ligneMessage.textContent = message;
toast.appendChild(ligneMessage);
if(erreur && agent){
const ligneAgent = document.createElement("div");
ligneAgent.className = "toast-agent";
ligneAgent.textContent = agent;
toast.appendChild(ligneAgent);
}
// Variante erreur (6 septembre 2026) : centrée sur le panneau de saisie,
// en rouge — voir usage dans validerFormulaireContact().
toast.classList.toggle("toast-erreur", !!erreur);
toast.style.display = "block";
clearTimeout(toastTimer);
toastTimer = setTimeout(()=>{ toast.style.display = "none"; }, 2600);
if(onValider){
// Toast bloquant (7 septembre 2026, avertissement IA — Jean-Marc : "je
// souhaite que le message s'affiche et reste en place [...] et que
// lorsque l'utilisateur [le referme], la fiche soit validée") : pas de
// disparition automatique ici. Fermeture uniquement via Échap depuis le
// 8 septembre 2026 (le bouton "Valider" initial est retiré — voir
// fermerToastValidation() et le gestionnaire keydown global) : on se
// contente de mémoriser le rappel à déclencher à la fermeture, rien
// d'autre à faire ici.
toastValidationEnCours = onValider;
} else {
toastValidationEnCours = null;
toastTimer = setTimeout(()=>{ toast.style.display = "none"; }, 2600);
}
}
// Referme le toast bloquant en cours (voir afficherToast(), paramètre
// onValider) et déclenche son rappel — appelée uniquement depuis le
// gestionnaire keydown global sur Échap (8 septembre 2026, voir plus bas).
function fermerToastValidation(){
const toast = document.getElementById("toast");
toast.style.display = "none";
toast.classList.remove("toast-erreur");
const onValider = toastValidationEnCours;
toastValidationEnCours = null;
if(onValider) onValider();
}
// ---- Bouton micro (pilotage vocal) ----
@@ -1369,22 +1584,10 @@ function afficherToast(message){
// partagé par tous les écrans — voir chargement en fin de fichier et le
// commentaire d'en-tête de micro.js pour le pourquoi de cette séparation.
// ---- Menu hamburger ----
const menuHamburger = document.getElementById("menu-hamburger");
document.getElementById("hamburger").addEventListener("click", (e)=>{
e.stopPropagation();
menuHamburger.style.display = menuHamburger.style.display === "block" ? "none" : "block";
});
document.addEventListener("click", ()=>{ menuHamburger.style.display = "none"; });
function fermerMenuHamburger(){
menuHamburger.style.display = "none";
}
// ToDoList (31 août 2026, voir journal) : nouvel écran d'entrée post-
// identification, accessible depuis ici par symétrie avec l'entrée
// "Gestion des contacts" du menu hamburger de la ToDoList elle-même.
document.getElementById("menu-vers-todolist").addEventListener("click", ()=>{
window.location.href = "liste_todolist.html";
});
// Menu hamburger supprimé le 3 septembre 2026 (voir journal) : plus de
// raison d'être depuis que l'écran de fond (ecran_fond.html) est le vrai
// écran d'accueil et qu'Échap y ramène directement depuis cet écran — voir
// le gestionnaire Échap plus bas.
// ---- Panneau filtre ----
const voile = document.getElementById("voile");
@@ -1668,7 +1871,7 @@ async function rendreRealisations(idContact){
ligne.className = "ligne-realisation " + (index % 2 === 0 ? "zebra-a" : "zebra-b");
ligne.innerHTML = `
<div class="col-rea col-rea-date">${formaterDateHeure(r.REA_Date)}</div>
<div class="col-rea col-rea-qui">${r.REA_CodeUser || ""}</div>
<div class="col-rea col-rea-qui">${r.REA_NomAffiche || r.REA_CodeUser || ""}</div>
<div class="col-rea col-rea-action">${r.ACT_Libelle || r.REA_Action || ""}</div>
<div class="col-rea col-rea-comment">${r.REA_Comment || ""}</div>
`;
@@ -1794,7 +1997,14 @@ async function chargerVersion(){
const reponse = await fetch("version.php");
if(!reponse.ok) throw new Error("HTTP " + reponse.status);
const data = await reponse.json();
if(data.version) document.getElementById("statut-version").textContent = data.version;
if(data.version){
document.getElementById("statut-version").textContent = data.version;
// Infobulle du groupe icône Mévélia + logo ASPIC + version (3
// septembre 2026, voir journal) : complétée ici une fois le numéro
// réel connu (posée à blanc "ASPIC" seul en HTML en attendant).
const elMarque = document.getElementById("marque-aspic");
if(elMarque) elMarque.title = "ASPIC " + data.version + "\nCréé par Mevelia Solutions";
}
} catch(erreur){
console.error("Erreur de chargement de version.php :", erreur);
}
@@ -1808,23 +2018,25 @@ async function chargerPreferences(){
if(typeof data.USR_RatioPanneauContact === "number") PREFERENCES.USR_RatioPanneauContact = data.USR_RatioPanneauContact;
if(typeof data.USR_RatioPanneauProspect === "number") PREFERENCES.USR_RatioPanneauProspect = data.USR_RatioPanneauProspect;
// Avatar (29 août 2026) : jusqu'ici du texte figé "JMP" dans le HTML,
// jamais vraiment branché sur la session — corrigé au passage à
// l'occasion du badge société ci-dessous (voir journal).
const elAvatar = document.getElementById("avatar");
if(elAvatar && data.USR_Code) elAvatar.textContent = data.USR_Code;
// Badge société (29 août 2026) : repère d'environnement, masqué si
// l'utilisateur connecté n'a pas encore de USR_Societe renseignée.
const elBadge = document.getElementById("badge-societe");
if(elBadge){
// Société et prénom de l'utilisateur (3 septembre 2026, voir journal) :
// barre de statut basse, texte simple — remplacent l'ancien badge
// société (cartouche bleu) et l'avatar (nom court) du bandeau haut,
// tous deux supprimés. Société masquée si USR_Societe est vide en base
// pour l'utilisateur connecté (comportement du badge conservé).
const elSociete = document.getElementById("societe-utilisateur");
const elPointSociete = document.getElementById("point-societe");
if(elSociete){
if(data.USR_Societe){
elBadge.textContent = data.USR_Societe;
elBadge.hidden = false;
elSociete.textContent = data.USR_Societe;
elSociete.hidden = false;
if(elPointSociete) elPointSociete.hidden = false;
} else {
elBadge.hidden = true;
elSociete.hidden = true;
if(elPointSociete) elPointSociete.hidden = true;
}
}
const elPrenom = document.getElementById("prenom-utilisateur");
if(elPrenom && data.USR_Prenom) elPrenom.textContent = data.USR_Prenom;
} catch(erreur){
console.error("Erreur de chargement de preferences.php :", erreur);
// Pas de toast : une préférence d'affichage non chargée retombe sur le
@@ -2023,6 +2235,7 @@ function fermerPanneauDetail(){
}
const btnEnregistrer = document.getElementById("btn-enregistrer");
const badgeIaActif = document.getElementById("badge-ia-actif");
const MODES_VALIDES = ["vue", "edition", "suppression"];
// Bouton + placeholder du bloc Informations : le texte dépend du mode ET,
@@ -2162,13 +2375,16 @@ async function lireReponse(reponse){
// réellement la création (génération de CNT_Code désormais implémentée côté
// contacts.php, REA_CodeUser lu depuis la session, voir journal section 16).
async function validerFormulaireContact(){
const nomInput = panneauDetail.querySelector('input.champ-edit[data-champ="CNT_Nom"]');
if(!nomInput.value.trim()){
afficherToast("Le nom est obligatoire.");
nomInput.focus();
return;
}
// Plus aucun contrôle écrit en dur ici (chantier des consignes, 6 septembre
// 2026, voir Réflexions stratégiques et moteur_consignes.php) : c'est
// contacts.php (POST/PUT) qui applique les consignes actives et renvoie,
// le cas échéant, le message d'erreur tel quel — affiché ci-dessous sans
// savoir à l'avance de quelle consigne il vient. Ajouter une consigne côté
// base ne demande donc plus aucune modification ici. Depuis le 7 septembre
// 2026, data.agent (CNS_Agent de la consigne en échec, facultatif) est lu
// en même temps que data.error et transmis à afficherToast() pour la ligne
// agent en italique — même principe : rien à modifier ici pour une future
// consigne, qu'elle porte un agent ou non.
const donnees = {};
CHAMPS_TEXTE_MODIFIABLES.forEach(nomChamp=>{
const el = panneauDetail.querySelector(
@@ -2183,8 +2399,72 @@ async function validerFormulaireContact(){
const commentaire = commentaireModification.value.trim();
const enModification = !!contactCourant;
// Badge IA (7 septembre 2026, soir — voir #badge-ia-actif dans le CSS pour
// le détail des décisions de Jean-Marc). Après deux allers-retours avec lui,
// AUCUNE condition n'est plus testée en dur ici ("Si tu introduis des tests
// en dur pour faire apparaître ou pas ce visuel, on sort de la logique
// initiale") : on interroge consignes_apercu.php, qui pose la même question
// au jumeau "_seraitDeclenchee" de la fonction elle-même (voir en-tête de
// consignes_fonctions.php et moteur_consignes.php) — la seule et unique
// définition de "l'IA va-t-elle être sollicitée pour ces valeurs". Ajouter
// une future consigne IA sur d'autres champs ne demandera donc toujours
// aucune modification ici, tant qu'elle porte son propre jumeau
// "_seraitDeclenchee" et FON_VisuelAttenteIA = 'O' en base.
//
// Appelé AVANT l'enregistrement réel (POST/PUT ci-dessous) : c'est ce qui
// permet au badge d'être déjà visible pendant l'attente de cet
// enregistrement, potentiellement plus lent quand l'IA est sollicitée.
// Repli silencieux sur "pas de badge" en cas de souci réseau sur cet appel
// lui-même — un aléa sur ce simple indicateur visuel ne doit jamais
// empêcher l'enregistrement du contact de continuer normalement.
let declenchementIaPrevu = false;
try {
const reponseApercu = await fetch("consignes_apercu.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
contexte: "Contacts",
portee: enModification ? "Modification" : "Création",
donnees
})
});
if(reponseApercu.ok){
const dataApercu = await reponseApercu.json();
declenchementIaPrevu = !!(dataApercu && dataApercu.declenche);
}
} catch(erreurApercu){
console.error("Erreur consignes_apercu.php :", erreurApercu);
}
// Bouton désactivé dans tous les cas (protection simple contre un double
// clic), mais SANS aucun changement de libellé pour un enregistrement
// classique — décision ferme de Jean-Marc : "en traitement simple de
// validation, le temps de traitement est imperceptible, aucune raison
// d'afficher quoi que ce soit". Le badge, bien plus visible (plein milieu
// de l'écran), n'apparaît que si consignes_apercu.php l'annonce — JAMAIS de
// façon systématique : "hors de question d'afficher systématiquement le
// badge IA, l'utilisateur pourrait s'affoler sur le coût systématique d'une
// insertion client". Restauré explicitement à chaque sortie (erreur réseau,
// erreur consigne, ou succès juste avant le changement de mode plus bas)
// plutôt que dans un bloc finally, pour rester à l'endroit exact où chaque
// cas se termine.
btnEnregistrer.disabled = true;
if(declenchementIaPrevu) badgeIaActif.style.display = "block";
const restaurerBouton = () => {
btnEnregistrer.disabled = false;
badgeIaActif.style.display = "none";
};
try {
let idContact;
// Avertissements (7 septembre 2026, nuit — voir moteur_consignes.php,
// clé réservée "_avertissement") : messages génériques que le serveur
// peut renvoyer en plus d'un enregistrement réussi (ex. l'IA a bien été
// sollicitée mais n'a rien pu en tirer). Affichés plus bas, une fois
// l'enregistrement confirmé — ce code ne sait pas, et n'a pas besoin de
// savoir, quelle consigne a pu en produire.
let avertissements = [];
if(enModification){
idContact = contactCourant.CNT_Incr;
const reponse = await fetch("contacts.php?id=" + encodeURIComponent(idContact), {
@@ -2195,9 +2475,13 @@ async function validerFormulaireContact(){
const {json: data, texte} = await lireReponse(reponse);
if(!reponse.ok || (data && data.error)){
console.error("Erreur PUT contacts.php :", reponse.status, data ? data.error : texte);
afficherToast("Erreur : impossible d'enregistrer les modifications.");
// Message précis du serveur (ex. une consigne en échec) si présent,
// message générique seulement en dernier recours (ex. panne réseau).
afficherToast((data && data.error) || "Erreur : impossible d'enregistrer les modifications.", true, data && data.agent);
restaurerBouton();
return;
}
avertissements = (data && data.avertissements) || [];
} else {
const reponse = await fetch("contacts.php", {
method: "POST",
@@ -2207,10 +2491,12 @@ async function validerFormulaireContact(){
const {json: data, texte} = await lireReponse(reponse);
if(!reponse.ok || !data || data.error || !data.id){
console.error("Erreur POST contacts.php :", reponse.status, data ? data.error : texte);
afficherToast("Erreur : impossible de créer ce contact.");
afficherToast((data && data.error) || "Erreur : impossible de créer ce contact.", true, data && data.agent);
restaurerBouton();
return;
}
idContact = data.id;
avertissements = (data && data.avertissements) || [];
}
// Contact bien enregistré à ce stade. La journalisation de la réalisation
@@ -2234,6 +2520,13 @@ async function validerFormulaireContact(){
: "Contact créé, mais l'historique n'a pas pu être journalisé.");
}
// Bouton réactivé et badge masqué avant le changement de mode ci-dessous :
// afficherDetailPourId() rebascule le panneau en mode "vue" (ou "edition",
// voir ci-dessous) et réactive déjà ce bouton à sa façon
// (mettreAJourLibellesActionPanneau()), donc rien de contradictoire à le
// faire ici en plus, juste avant.
restaurerBouton();
// Retour sur le panneau de visualisation du contact (25 août 2026, pour
// modification ET création) plutôt que fermeture/retour liste — meilleure
// façon de voir que l'enregistrement a bien été pris en compte. Pas de
@@ -2242,13 +2535,45 @@ async function validerFormulaireContact(){
// arrière-plan) avant de réafficher le détail : la barre de position et
// la navigation PgUp/PgDn du panneau détail doivent refléter le tri/
// filtre à jour de contactsAffiches, pas son état d'avant enregistrement.
//
// S'il y a un avertissement, on reste d'abord en mode édition au lieu de
// repasser en vue, pour que l'utilisateur puisse corriger tout de suite
// le champ resté incomplet (ex. code postal non déduit). On appelle
// quand même afficherDetailPourId (et non un simple maintien d'affichage)
// afin que contactCourant soit bien rafraîchi avec le contact tel
// qu'enregistré côté serveur (id inclus) : indispensable en création pour
// qu'un second clic sur "Enregistrer" fasse un PUT sur ce contact et non
// un nouveau POST (pas de doublon).
await rendreListe();
await afficherDetailPourId(idContact, "vue");
await afficherDetailPourId(idContact, avertissements.length ? "edition" : "vue");
// Avertissement(s) du serveur (voir plus haut) : affiché(s) une fois le
// panneau réaffiché en mode édition ci-dessus, plusieurs messages étant
// regroupés en un seul toast, un par ligne (#toast a déjà
// white-space:pre-line pour un CNS_MessageErreur multi-lignes, réutilisé
// ici tel quel). Rendu en erreur (true) bien que l'enregistrement du
// contact ait réussi : c'est un échec de l'IA (ex. code postal non
// déduit) qui appelle une action de l'utilisateur.
// Toast bloquant (7 septembre 2026, demande explicite de Jean-Marc :
// "je souhaite que le message s'affiche et reste en place [...] et que
// lorsque l'utilisateur [le referme], la fiche soit validée") : pas de
// disparition automatique — la fiche reste visuellement "en attente"
// (mode édition) tant que l'utilisateur n'a pas fermé le toast (Échap
// seul depuis le 8 septembre 2026, voir afficherToast()), ce qui ne fait
// alors que rebasculer l'affichage en mode vue (le contact est déjà bel
// et bien enregistré depuis plus haut, avertissement ou non — cette
// fermeture ne redéclenche aucun enregistrement).
if(avertissements.length){
afficherToast(avertissements.join("\n"), true, undefined, () => {
basculerModeDetail("vue");
});
}
} catch(erreur){
console.error("Erreur d'enregistrement :", erreur);
afficherToast(enModification
? "Erreur : impossible d'enregistrer les modifications."
: "Erreur : impossible de créer ce contact.");
: "Erreur : impossible de créer ce contact.", true);
restaurerBouton();
}
}
+3
View File
@@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8">
<title>ASPIC — ToDoList</title>
<!-- Icône d'onglet (3 septembre 2026, voir journal) : logo Mévélia Solutions,
fourni par Jean-Marc, reprise à l'identique sur tous les écrans. -->
<link rel="icon" href="mevelia-icone.png">
<style>
@font-face{
font-family:"Metro DF";
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+16 -3
View File
@@ -21,6 +21,14 @@
* 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).
*
* Exception à cette structure (3 septembre 2026) : l'écran de fond
* (ecran_fond.html) porte le même comportement sur un bouton #micro-desk
* plutôt que #bouton-micro — sa zone cliquable réelle est volontairement
* plus grande que l'icône affichée par-dessus, calée sur toute la
* silhouette du micro photographié (voir le commentaire dédié dans
* ecran_fond.html), donc pas la même structure DOM que les bandeaux. Voir
* plus bas la recherche du bouton avec repli sur cet id.
*
* É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
@@ -132,8 +140,11 @@
(function(){
"use strict";
const bouton = document.getElementById("bouton-micro");
if(!bouton) return; // écran sans bouton micro dans son bandeau
// #bouton-micro sur les écrans à bandeau, #micro-desk en repli sur
// l'écran de fond (voir commentaire d'en-tête) — un seul des deux existe
// sur une page donnée.
const bouton = document.getElementById("bouton-micro") || document.getElementById("micro-desk");
if(!bouton) return; // écran sans bouton micro
function notifier(message){
if(typeof window.afficherToast === "function"){
@@ -421,7 +432,9 @@
.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); });
// AGN_Nom renommée AGN_Prenom en base (7 septembre 2026, nuit) — voir
// agents.php, qui renvoie désormais cette clé sous son nouveau nom.
agentsDisponibles = liste.map(function(a){ return construireAgent(a.AGN_Prenom, a.AGN_Variantes); });
agentsPrets = true;
})
.catch(function(){
+614
View File
@@ -0,0 +1,614 @@
/**
* ASPIC — micro_test.js
*
* Outil de mise au point interne (3 septembre 2026, demande explicite de
* Jean-Marc) : PAS un futur module utilisateur. Sert uniquement à
* constituer une banque de dictées réelles (table ASP_DicteesTest, thème
* "Gestion des Contacts") pour préparer, plus tard, la conception des
* règles d'interprétation du pilotage vocal — voir réflexions
* stratégiques et le commentaire d'en-tête de dictees_test.php.
*
* Chargé À LA PLACE de micro.js sur ecran_fond.html, le temps de cette
* phase de mise au point (voir le commentaire dédié dans ecran_fond.html,
* autour de #micro-desk) — micro.js (le vrai pilotage : agents nommés,
* mot déclencheur, mode notation) reste totalement inchangé et continue de
* tourner normalement sur liste_contacts.html. Pour reconnecter le vrai
* pilotage sur le micro de l'écran de fond une fois cette phase terminée :
* remettre <script src="micro.js"> à la place de
* <script src="micro_test.js"> en bas d'ecran_fond.html, rien d'autre à
* défaire — ce fichier-ci n'est jamais modifié pour ça.
*
* Différences volontaires avec micro.js, toutes actées avec Jean-Marc :
* - Aucun mot déclencheur : le clic sur le micro fait déjà office de
* signal explicite de début (contrairement à l'écoute ambiante continue
* de micro.js, qui doit filtrer une conversation de fond d'une vraie
* commande — ce problème ne se pose pas ici, on sait que tout ce qui est
* dit après le clic est un essai volontaire).
* - Aucune notion d'agent, de mode notation, de phrase de clôture : chaque
* énoncé séparé par un silence est une ligne indépendante, enregistrée
* immédiatement dès qu'il est reconnu — la segmentation elle-même n'est
* pas gérée à la main ici, c'est le moteur de reconnaissance du
* navigateur qui la fait (un événement "result" avec isFinal=true
* correspond déjà à "ce qui a été dit avant une pause" ; on ne choisit
* pas la durée exacte de cette pause, elle est interne au navigateur —
* même mécanisme que celui déjà éprouvé en production dans micro.js).
* **Exception : le "mode adresse", voir plus bas.**
* - Restitution au fur et à mesure dans une fenêtre dédiée (fond noir,
* texte blanc, défilement) : la ligne en cours s'affiche pendant la
* dictée (interimResults activé, contrairement à micro.js) puis se fige
* et se sauvegarde dès que le silence est détecté par le navigateur ;
* une nouvelle ligne en cours démarre aussitôt après.
* - Aucune suppression possible depuis l'écran, y compris pour un essai
* raté (décision explicite de Jean-Marc, 3 septembre 2026) : un post-it
* raté fera aussi partie de la vraie vie des futurs utilisateurs, la
* banque de tests doit refléter ça dès maintenant plutôt que ne garder
* que les essais réussis.
* - Fenêtre ouverte à chaque ouverture du micro, vidée à chaque fermeture
* (elle ne conserve donc rien visuellement d'une session à l'autre — la
* table ASP_DicteesTest, elle, continue évidemment d'accumuler tous les
* essais, la fenêtre n'est qu'un retour visuel immédiat).
*
* Reprend, pour la robustesse, le même squelette que micro.js pour le
* redémarrage silencieux du moteur (qui peut s'arrêter tout seul sans que
* l'utilisateur l'ait demandé) et la coupure automatique après inactivité
* prolongée (même prudence de confidentialité que celle qui avait conduit
* à cet ajout dans micro.js — voir son commentaire d'en-tête) — sans
* dépendre de micro.js lui-même : ce fichier est volontairement autonome et
* plus simple, pour ne jamais risquer de contaminer le vrai pilotage avec
* de la logique de test jetable.
*
* ---------------------------------------------------------------------
* AJOUT DU 3 SEPTEMBRE 2026 (soirée) — "Mode adresse" (email / URL).
* ---------------------------------------------------------------------
* Origine : essais réels de Jean-Marc montrant que la dictée d'une adresse
* mail échoue très souvent (voir réflexions stratégiques). Deux causes
* distinctes identifiées à partir d'un export réel de ASP_DicteesTest :
*
* 1. Le mot "point" (et "tiret", "arobase", "tiret du bas"...) n'est
* converti en symbole ("." "-" "@" "_") que de façon incohérente par le
* moteur gratuit du navigateur — parfois converti, parfois laissé en
* toutes lettres, parfois avalé. "tiret" est en plus régulièrement
* confondu avec le verbe "tirer" (homophone). Un problème de
* *vocabulaire*, rattrapable par un dictionnaire de conversion.
* 2. Le moteur découpe une même adresse dictée en plusieurs lignes
* indépendantes (plusieurs événements isFinal) sans que la durée du
* silence permette de distinguer fiablement "il continue la même
* adresse" de "il change de sujet" (des écarts de 3-4 secondes
* s'observent dans les deux cas sur le corpus réel). Un problème de
* *segmentation*, qu'un dictionnaire seul ne peut pas résoudre.
*
* Le "mode adresse" répond aux deux à la fois en réutilisant le principe
* déjà éprouvé du mode notation de micro.js (ouverture/clôture explicite)
* mais ici au clic plutôt qu'à la phrase, pour ne pas dépendre d'un mot
* de clôture qui pourrait lui-même être mal reconnu :
* - Activé via le bouton "Mode adresse" de la fenêtre de test.
* - Tant qu'il est actif, chaque fragment reconnu (isFinal) est empilé
* dans un buffer au lieu d'être enregistré comme une ligne indépendante
* — la segmentation du moteur n'a donc plus d'importance.
* - Le bouton "Valider" (ou la désactivation du mode) clôt le buffer :
* les fragments sont concaténés, passés à normaliserTexteAdresse() qui
* applique le dictionnaire de conversion et retire les espaces
* restants (une adresse mail/URL n'en contient jamais), puis le résultat
* est comparé à une forme attendue (email ou domaine) pour un simple
* retour visuel (vert/orange) — indicatif seulement, n'empêche jamais
* l'enregistrement (cohérent avec la règle "aucun essai raté supprimé").
* - Le texte brut (tel que dicté, fragments concaténés) ET le texte
* normalisé sont envoyés à dictees_test.php et conservés tous les deux
* (DCT_Texte / DCT_TexteNormalise) — rien n'écrase la matière brute déjà
* actée comme précieuse pour la suite ; DCT_TexteNormalise est une
* colonne en plus, NULL pour toutes les lignes hors "mode adresse".
* ⚠️ Nécessite l'ajout manuel de cette colonne en base avant déploiement
* (voir le message livré avec ce fichier — Claude n'exécute jamais de
* modification de schéma lui-même, cf. réflexions stratégiques).
* - Un compteur simple (conforme / non conforme) s'affiche dans la fenêtre
* pour donner une lecture immédiate du taux d'échec au fil des essais.
*
* Portée volontairement limitée à ce problème précis : aucune tentative
* de deviner where s'arrête une adresse dans un flux libre, aucune
* interprétation sémantique — juste un buffer explicite et un dictionnaire
* de conversion, dans le même esprit "pas de sur-ingénierie" que le reste
* du projet.
*
* ---------------------------------------------------------------------
* CORRECTIF DU 3 SEPTEMBRE 2026 (fin de soirée) — fenêtre effacée à
* chaque redémarrage automatique du moteur (bug préexistant, pas lié au
* "mode adresse" ci-dessus).
* ---------------------------------------------------------------------
* `ouvrirFenetre()` (qui vide entièrement le contenu affiché) était
* appelée à chaque événement "start" du moteur de reconnaissance — or ce
* moteur se coupe et se relance tout seul en silence après une pause
* (comportement voulu, voir plus haut), ce qui déclenche un nouvel
* événement "start" à chaque fois. Avec des dictées enchaînées, ça ne se
* voyait pas ; avec une dictée hésitante (adresse mail, beaucoup de
* silences de réflexion), les redémarrages deviennent fréquents et la
* fenêtre se vide sans cesse avant d'avoir pu être lue — signalé par
* l'indicateur micro de Chrome qui clignote en continu. Corrigé en ne
* réinitialisant la fenêtre qu'au tout premier "start" suivant un clic
* utilisateur réel (`ouvertureInitiale`), jamais lors d'une relance
* automatique. Un bloc de diagnostic temporaire (`DIAGNOSTIC_ACTIF`,
* fonction logDiag) a été ajouté en parallèle pour visualiser les cycles
* démarrage/arrêt/erreur en direct dans la fenêtre — à retirer une fois
* le comportement confirmé stable.
*
* Ce diagnostic a révélé un second problème, plus grave : dans certaines
* conditions le moteur enchaîne des centaines de redémarrages par seconde
* (des "aborted" apparaissent parmi les erreurs), sans aucun frein côté
* code d'origine (relance immédiate à chaque "end", aucun délai). Ajout
* d'un coupe-circuit (`cyclesRapidesConsecutifs` / `MAX_CYCLES_RAPIDES`) :
* au-delà de 5 redémarrages consécutifs en moins de 800 ms, l'écoute
* s'arrête proprement avec un message, plutôt que de boucler indéfiniment
* et de noyer la fenêtre de diagnostic.
*/
(function(){
"use strict";
const bouton = document.getElementById("micro-desk");
if(!bouton) return; // ce script ne concerne que l'écran de fond
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
if(!ReconnaissanceVocale){
bouton.title = "Reconnaissance vocale non disponible sur ce navigateur";
return;
}
const reconnaissance = new ReconnaissanceVocale();
reconnaissance.lang = "fr-FR";
reconnaissance.continuous = true;
reconnaissance.interimResults = true; // restitution au fur et à mesure, demandée par Jean-Marc
reconnaissance.maxAlternatives = 1;
let ecouteVoulue = false;
let erreurBloquante = false;
// Coupure automatique après inactivité — même prudence que micro.js
// (voir son commentaire d'en-tête sur l'incident de confidentialité qui
// avait motivé cet ajout) : un outil de test resté ouvert par oubli ne
// doit pas non plus continuer à envoyer l'audio ambiant indéfiniment.
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
ecouteVoulue = false;
reconnaissance.stop();
}
// -------- Mode adresse : dictionnaire de conversion --------
// Volontairement limité aux mots utiles à une adresse mail/URL dictée en
// français — jamais appliqué en dehors du "mode adresse" (voir plus bas),
// pour ne jamais risquer de transformer un "point" ou un "tiret" dit dans
// une phrase normale ailleurs dans l'outil.
const REMPLACEMENTS_ADRESSE = [
[/\btiret du bas\b/g, "_"],
[/\btiret bas\b/g, "_"],
[/\bunderscore\b/g, "_"],
[/\barobase\b/g, "@"],
[/\barobas\b/g, "@"],
[/\btrait d'union\b/g, "-"],
// "tirer" (verbe) est une confusion acoustique fréquente avec "tiret"
// dans ce contexte précis (voir export du 3 septembre) — alias
// volontairement restreint au mode adresse, jamais appliqué ailleurs.
[/\btiret\b/g, "-"],
[/\btirer\b/g, "-"],
[/\bpoint\b/g, "."]
];
function normaliserTexteAdresse(brut){
let t = " " + brut.toLowerCase().trim() + " ";
REMPLACEMENTS_ADRESSE.forEach(function(paire){
t = t.replace(paire[0], paire[1]);
});
// Une adresse mail ou une URL ne contient jamais d'espace : tout espace
// restant à ce stade vient de la reconnaissance vocale elle-même
// (silences entre syllabes/mots), pas d'une intention de l'utilisateur.
t = t.replace(/\s+/g, "");
return t;
}
const FORME_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FORME_DOMAINE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
function evaluerConformite(normalise){
if(FORME_EMAIL.test(normalise)) return {ok:true, categorie:"email"};
if(FORME_DOMAINE.test(normalise)) return {ok:true, categorie:"domaine"};
return {ok:false, categorie:null};
}
// -------- Fenêtre de restitution --------
// Construite ici plutôt que dans ecran_fond.html, pour que ce fichier
// reste entièrement autonome — le retirer (remettre micro.js) ne laisse
// aucune trace HTML/CSS à nettoyer côté page hôte.
let fenetre = null;
let zoneLignes = null;
let ligneEnCours = null;
let boutonModeAdresse = null;
let boutonValider = null;
let zoneBuffer = null;
let compteurEl = null;
// État du mode adresse
let modeAdresse = false;
let bufferFragments = []; // fragments isFinal accumulés depuis l'activation/le dernier "Valider"
let compteurConforme = 0;
let compteurNonConforme = 0;
function construireFenetre(){
if(fenetre) return;
const style = document.createElement("style");
style.textContent =
"#fenetre-dictees-test{position:fixed;top:8%;left:50%;" +
"transform:translateX(-50%);width:min(560px,80vw);height:min(460px,72vh);" +
"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;}" +
"#fenetre-dictees-test.visible{display:flex;}" +
"#fenetre-dictees-test .entete{color:#888;font-size:11px;" +
"padding:6px 10px;border-bottom:1px solid #222;flex:none;}" +
"#fenetre-dictees-test .controles{display:flex;align-items:center;" +
"gap:8px;padding:6px 10px;border-bottom:1px solid #222;flex:none;}" +
"#fenetre-dictees-test .controles button{font:inherit;font-size:12px;" +
"background:#1a1a1a;color:#ccc;border:1px solid #444;border-radius:4px;" +
"padding:4px 10px;cursor:pointer;}" +
"#fenetre-dictees-test .controles button.actif{background:#2a4d2a;" +
"color:#bdf7bd;border-color:#3a6d3a;}" +
"#fenetre-dictees-test .controles button:disabled{opacity:.4;" +
"cursor:default;}" +
"#fenetre-dictees-test .controles .compteur{margin-left:auto;" +
"color:#777;font-size:11px;}" +
"#fenetre-dictees-test .buffer{padding:6px 10px;color:#ffd76a;" +
"font-size:13px;border-bottom:1px solid #222;min-height:1.4em;" +
"flex:none;white-space:pre-wrap;}" +
"#fenetre-dictees-test .lignes{flex:1;overflow-y:auto;padding:8px 10px;" +
"color:#fff;font-size:14px;line-height:1.6;white-space:pre-wrap;}" +
"#fenetre-dictees-test .lignes .en-cours{color:#8a8a8a;font-style:italic;}" +
"#fenetre-dictees-test .lignes .erreur{color:#e14a5c;}" +
"#fenetre-dictees-test .lignes .adresse-ok{color:#7be07b;}" +
"#fenetre-dictees-test .lignes .adresse-ko{color:#e0a95a;}" +
"#fenetre-dictees-test .lignes .adresse-brut{color:#777;font-size:12px;}" +
"#fenetre-dictees-test .lignes .diag{color:#6ab8ff;font-size:11px;}";
document.head.appendChild(style);
fenetre = document.createElement("div");
fenetre.id = "fenetre-dictees-test";
fenetre.innerHTML =
'<div class="entete">Banque de dictées — Gestion des Contacts ' +
'(outil de mise au point, non destiné aux utilisateurs finaux)</div>' +
'<div class="controles">' +
'<button type="button" class="btn-mode-adresse">Mode adresse : OFF</button>' +
'<button type="button" class="btn-valider" disabled>Valider</button>' +
'<span class="compteur"></span>' +
'</div>' +
'<div class="buffer"></div>' +
'<div class="lignes"></div>';
document.body.appendChild(fenetre);
zoneLignes = fenetre.querySelector(".lignes");
zoneBuffer = fenetre.querySelector(".buffer");
boutonModeAdresse = fenetre.querySelector(".btn-mode-adresse");
boutonValider = fenetre.querySelector(".btn-valider");
compteurEl = fenetre.querySelector(".compteur");
boutonModeAdresse.addEventListener("click", function(){
if(modeAdresse){
// Désactivation : on ne perd jamais un buffer en cours, on le
// valide d'abord (même logique que le bouton "Valider").
if(bufferFragments.length) validerBufferAdresse();
modeAdresse = false;
boutonModeAdresse.textContent = "Mode adresse : OFF";
boutonModeAdresse.classList.remove("actif");
boutonValider.disabled = true;
afficherBuffer();
} else {
modeAdresse = true;
boutonModeAdresse.textContent = "Mode adresse : ON";
boutonModeAdresse.classList.add("actif");
boutonValider.disabled = false;
}
});
boutonValider.addEventListener("click", function(){
validerBufferAdresse();
});
}
function majCompteur(){
if(!compteurEl) return;
const total = compteurConforme + compteurNonConforme;
compteurEl.textContent = total
? "adresses : " + compteurConforme + "/" + total + " conformes"
: "";
}
function afficherBuffer(){
if(!zoneBuffer) return;
zoneBuffer.textContent = modeAdresse
? "[mode adresse] " + bufferFragments.join(" ")
: "";
}
function validerBufferAdresse(){
const brutConcatene = bufferFragments.join(" ").trim();
bufferFragments = [];
afficherBuffer();
if(!brutConcatene) return;
const normalise = normaliserTexteAdresse(brutConcatene);
const conformite = evaluerConformite(normalise);
if(conformite.ok) compteurConforme++; else compteurNonConforme++;
majCompteur();
const ligneBrut = document.createElement("div");
ligneBrut.className = "adresse-brut";
ligneBrut.textContent = "brut : " + brutConcatene;
zoneLignes.appendChild(ligneBrut);
const ligneNormalisee = document.createElement("div");
ligneNormalisee.className = conformite.ok ? "adresse-ok" : "adresse-ko";
ligneNormalisee.textContent = "→ " + normalise +
(conformite.ok ? " (" + conformite.categorie + " reconnu)" : " (forme non reconnue)");
zoneLignes.appendChild(ligneNormalisee);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
enregistrerDictee(brutConcatene, normalise);
reinitialiserMinuteurInactivite();
}
function ouvrirFenetre(){
construireFenetre();
zoneLignes.innerHTML = "";
ligneEnCours = null;
bufferFragments = [];
compteurConforme = 0;
compteurNonConforme = 0;
majCompteur();
afficherBuffer();
fenetre.classList.add("visible");
}
function fermerFenetre(){
if(fenetre) fenetre.classList.remove("visible");
if(zoneLignes) zoneLignes.innerHTML = "";
ligneEnCours = null;
bufferFragments = [];
}
function afficherEnCours(texte){
if(!zoneLignes) return;
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
ligneEnCours.className = "en-cours";
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.textContent = texte;
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function figerLigne(texte){
if(!zoneLignes) return;
if(!ligneEnCours){
ligneEnCours = document.createElement("div");
zoneLignes.appendChild(ligneEnCours);
}
ligneEnCours.className = "";
ligneEnCours.textContent = texte;
ligneEnCours = null; // la prochaine ligne (en cours ou figée) en recrée une
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
function afficherErreur(texte){
if(!zoneLignes) return;
const div = document.createElement("div");
div.className = "erreur";
div.textContent = texte;
zoneLignes.appendChild(div);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
// -------- DIAGNOSTIC TEMPORAIRE (3 septembre 2026, soir) --------
// Ajouté uniquement pour comprendre pourquoi rien ne s'affiche chez
// Jean-Marc (indicateur micro de Chrome qui clignote sans arrêt — signe
// possible d'un cycle démarrage/arrêt en boucle rapide). Affiche TOUT,
// y compris les événements normalement filtrés (ex. "no-speech"), avec
// horodatage précis. À RETIRER une fois le problème identifié — ce n'est
// pas un affichage destiné à rester.
const DIAGNOSTIC_ACTIF = true;
let diagCompteurDemarrages = 0;
function logDiag(texte){
if(!DIAGNOSTIC_ACTIF || !zoneLignes) return;
const maintenant = new Date();
const heure = maintenant.toLocaleTimeString("fr-FR") + "." +
String(maintenant.getMilliseconds()).padStart(3, "0");
const div = document.createElement("div");
div.className = "diag";
div.textContent = "[diag " + heure + "] " + texte;
zoneLignes.appendChild(div);
zoneLignes.scrollTop = zoneLignes.scrollHeight;
}
// -------- Enregistrement --------
// Aucune interprétation, aucun filtrage : tout ce qui est capté part vers
// dictees_test.php, essai raté compris (décision de Jean-Marc, voir
// commentaire d'en-tête). En mode adresse, le texte normalisé est envoyé
// en plus (jamais à la place) du texte brut — voir commentaire d'en-tête
// du 3 septembre sur DCT_TexteNormalise.
function enregistrerDictee(texte, texteNormalise){
const corps = {texte: texte};
if(texteNormalise) corps.texteNormalise = texteNormalise;
fetch("dictees_test.php", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(corps)
}).then(function(reponse){
if(!reponse.ok) throw new Error("HTTP " + reponse.status);
}).catch(function(){
afficherErreur("(non enregistré — erreur de connexion à dictees_test.php)");
});
}
// Vrai uniquement pour le tout prochain événement "start" déclenché par un
// clic utilisateur réel — jamais pour une relance automatique après coupure
// silencieuse (voir diagnostic du 3 septembre, soir : la fenêtre entière
// était effacée à CHAQUE redémarrage du moteur, pas seulement à l'ouverture
// volontaire, ce qui devenait très visible dès que les silences — donc les
// redémarrages — se multipliaient pendant une dictée hésitante).
let ouvertureInitiale = false;
function passerEnEcoute(){
bouton.classList.add("ecoute");
bouton.title = "Le micro est ouvert (banque de dictées contacts — outil de mise au point)";
if(ouvertureInitiale){
ouvertureInitiale = false;
ouvrirFenetre(); // construit/affiche et réinitialise réellement, une seule fois
} else if(fenetre){
fenetre.classList.add("visible"); // relance automatique : rien n'est effacé
}
reinitialiserMinuteurInactivite();
}
function revenirAuRepos(){
ecouteVoulue = false;
arreterMinuteurInactivite();
bouton.classList.remove("ecoute");
bouton.title = "Le micro est inactif";
fermerFenetre();
}
bouton.addEventListener("click", function(){
if(ecouteVoulue){
// Fermeture demandée : le retour au repos (et la fermeture de la
// fenêtre) se fait via l'événement "end" qui va suivre.
ecouteVoulue = false;
reconnaissance.stop();
return;
}
ecouteVoulue = true;
erreurBloquante = false;
ouvertureInitiale = true;
try{
reconnaissance.start();
}catch(erreur){
ecouteVoulue = false;
ouvertureInitiale = false;
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
}
});
// Coupe-circuit anti-boucle (3 septembre 2026, fin de soirée) : le
// diagnostic en direct a montré des centaines de redémarrages par
// seconde dans certaines conditions (service en défaut, ou toute autre
// cause empêchant le moteur de rester ouvert) — sans aucun frein, le code
// d'origine relançait immédiatement à chaque "end", ce qui peut boucler
// indéfiniment et rendre la fenêtre illisible. Si plusieurs redémarrages
// se suivent anormalement vite, on arrête proprement plutôt que de
// continuer à boucler en silence.
let dernierDemarrageHorodatage = 0;
let cyclesRapidesConsecutifs = 0;
const SEUIL_CYCLE_RAPIDE_MS = 800;
const MAX_CYCLES_RAPIDES = 5;
reconnaissance.addEventListener("start", function(){
diagCompteurDemarrages++;
dernierDemarrageHorodatage = Date.now();
passerEnEcoute();
logDiag("start #" + diagCompteurDemarrages);
});
reconnaissance.addEventListener("end", function(){
logDiag("end (ecouteVoulue=" + ecouteVoulue + ", erreurBloquante=" + erreurBloquante + ")");
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 (" +
cyclesRapidesConsecutifs + " fois de suite en moins de " +
SEUIL_CYCLE_RAPIDE_MS + " ms) — arrêt automatique pour ne pas " +
"boucler indéfiniment. Attends quelques secondes puis rouvre le micro.");
return;
}
// Le moteur s'est arrêté tout seul (silence prolongé, limite interne
// du navigateur...) alors que l'écoute reste voulue : relance
// invisible pour l'utilisateur, même principe que micro.js.
try{
reconnaissance.start();
}catch(erreur){
revenirAuRepos();
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
}
return;
}
revenirAuRepos();
});
reconnaissance.addEventListener("result", function(evenement){
const dernier = evenement.results[evenement.results.length - 1];
const texte = dernier[0].transcript.trim();
if(!texte) return;
if(dernier.isFinal){
if(modeAdresse){
// Accumulation dans le buffer plutôt qu'enregistrement immédiat —
// voir commentaire d'en-tête du 3 septembre sur le "mode adresse".
bufferFragments.push(texte);
afficherBuffer();
} else {
// Silence détecté par le moteur : la ligne se fige et s'enregistre
// immédiatement, essai raté ou non — aucun jugement porté ici sur
// la qualité du texte reconnu.
figerLigne(texte);
enregistrerDictee(texte, null);
}
reinitialiserMinuteurInactivite();
} else {
// Restitution au fur et à mesure, avant que le silence ne fige la
// ligne — demande explicite de Jean-Marc.
if(modeAdresse){
zoneBuffer.textContent = "[mode adresse] " +
(bufferFragments.concat([texte])).join(" ");
} else {
afficherEnCours(texte);
}
}
});
reconnaissance.addEventListener("error", function(evenement){
logDiag("error : " + evenement.error);
const messagesBloquants = {
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
"audio-capture": "Aucun micro détecté."
};
if(messagesBloquants[evenement.error]){
erreurBloquante = true;
afficherErreur(messagesBloquants[evenement.error]);
return;
}
// "no-speech" est un bruit normal entre deux essais (silence pendant
// qu'on réfléchit à la phrase suivante) : pas affiché, pour ne pas
// polluer la fenêtre de tests — l'événement "end" qui suit relance de
// toute façon l'écoute tant qu'elle reste voulue.
if(evenement.error === "network"){
afficherErreur("(problème réseau, nouvelle tentative...)");
}
});
})();
+540
View File
@@ -0,0 +1,540 @@
<?php
/**
* ASPIC — mission_agent.php
*
* Endpoint interne (11 septembre 2026, suite du chantier Compétences/Missions
* — voir ASP_Competences/ASP_AgentsCompetences/ASP_Missions/ASP_SitesWeb/
* ASP_SitesWebFichiers) — PAS un endpoint utilisateur, même famille que
* collaborateur_dialogue.php, mais AVEC effets de bord réels cette fois :
* c'est la différence de fond entre "Tester l'identité" (dialogue pur, aucun
* outil) et une Mission (dialogue + outils + fichiers réellement écrits).
* D'où un fichier séparé plutôt qu'une extension de collaborateur_dialogue.php
* — un bac à sable sans effet de bord ne doit jamais devenir, par petites
* touches, un endpoint qui écrit sur disque.
*
* Reçoit (POST, JSON) : { "MIS_Incr": 5, "message": "..." }
* — UN SEUL message par appel, pas tout l'historique : contrairement à
* collaborateur_dialogue.php, l'historique complet des échanges (texte ET
* appels d'outils, format Anthropic) est conservé côté serveur, dans
* ASP_Missions.MIS_HistoriqueJson. Choix délibéré de Jean-Marc (10-11
* septembre 2026) : une mission doit être reprenable depuis n'importe quel
* ordinateur, sans dépendre d'un état conservé dans l'onglet du navigateur.
* Renvoie { "reponse": "...", "outils": [...] } — "outils" liste les appels
* d'outils réellement exécutés pendant ce tour (nom, résumé), pour que le
* front-end puisse afficher "Sophia a créé index.html" sans avoir à
* redevoiler tout l'historique brut.
*
* Compétences (11 septembre 2026) : le paquet d'outils envoyé au modèle
* dépend UNIQUEMENT de MIS_CompetenceCode, jamais d'une liste éditable en
* base — même principe que le catalogue de consignes curaté par Jean-Marc
* (Réflexions stratégiques, section 18) : c'est le code, pas une donnée
* modifiable, qui définit ce qu'un outil fait réellement. Une seule
* compétence câblée pour l'instant ('SITE_WEB', voir outilsDeLaCompetence()
* ci-dessous) — à généraliser (table de correspondance, etc.) seulement
* quand une deuxième compétence réelle se présentera (même discipline que le
* reste du projet : généraliser à partir de 3 cas constatés, pas avant).
*
* Sécurité du bac à sable fichiers (voir ecrireFichierSite() ci-dessous) :
* chemin relatif uniquement, extensions whitelistées (pas de PHP, pas
* d'exécutable), résolution via realpath() vérifiée sous la racine du site
* — jamais de confiance dans le chemin fourni par le modèle, même s'il n'est
* pas malveillant par construction (défense en profondeur, même principe que
* partout ailleurs dans ASPIC).
*
* Stockage (voir Réflexions stratégiques section 21, principe transposé) :
* - Brouillon : /var/www/sites_brouillons/{ASPIC_DB_NAME}/{SIT_Incr}/ — hors
* de la racine servie par Apache (volume Docker séparé, ajouté le 11
* septembre 2026), donc AUCUNE URL directe ne peut jamais l'atteindre.
* - Publication : voir sites_web.php, une action humaine explicite, jamais
* déclenchée par le modèle lui-même.
*
* Clôture de mission (MIS_Statut, MIS_BilanCloture, enrichissement
* d'AGN_Experience) : PAS traitée ici, laissée pour un prochain endpoint —
* ce fichier refuse simplement de continuer une mission déjà close (voir
* plus bas), jamais silencieusement.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/journal_ia.php';
require __DIR__ . '/moteur_prompts.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
// Prénom de l'utilisateur connecté (posé en session par aspic.php au login),
// jamais "Jean-Marc" en dur dans le prompt/le journal (11 septembre 2026,
// point relevé par Jean-Marc lui-même : ASPIC est censé pouvoir un jour
// tourner pour un autre patron, Joseph ou Sylvie).
$patron = trim((string) ($_SESSION['USR_Prenom'] ?? '')) ?: "l'utilisateur";
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
const ANTHROPIC_MODEL = 'claude-sonnet-5';
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
const ANTHROPIC_MAX_TOKENS = 4096; // plus haut que collaborateur_dialogue.php : un tool_use peut porter tout le contenu d'une page HTML
const MAX_TOURS_OUTILS = 6; // plafond d'aller-retours outil par message, pour ne jamais laisser un enchaînement s'emballer (même discipline que consigne_completer_code_postal())
const EXTENSIONS_AUTORISEES_SITE = ['html', 'css', 'js', 'json', 'svg'];
const RACINE_BROUILLONS = '/var/www/sites_brouillons';
const RACINE_PUBLIEES = __DIR__ . '/sites_publies';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$misIncr = (int) ($data['MIS_Incr'] ?? 0);
$message = trim((string) ($data['message'] ?? ''));
if ($misIncr <= 0) {
http_response_code(400);
echo json_encode(['error' => 'MIS_Incr manquant ou invalide']);
exit;
}
if ($message === '') {
http_response_code(400);
echo json_encode(['error' => 'Message vide']);
exit;
}
$apiKey = trim((string) getenv('ANTHROPIC_API_KEY'));
if (!$apiKey) {
http_response_code(500);
echo json_encode(['error' => "Clé ANTHROPIC_API_KEY absente côté serveur."]);
exit;
}
$workspaceId = trim((string) getenv('ANTHROPIC_WORKSPACE_ID'));
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
// -------- Chargement de la mission, du collaborateur, de la compétence, du site --------
$stmt = $pdo->prepare(
'SELECT m.MIS_Incr, m.MIS_AgentIncr, m.MIS_CompetenceCode, m.MIS_SiteIncr,
m.MIS_Titre, m.MIS_Objectif, m.MIS_Statut, m.MIS_Journal, m.MIS_HistoriqueJson,
a.AGN_Prenom, a.AGN_Fonction, a.AGN_Identite, a.AGN_Experience, a.AGN_Competences,
c.CMP_Libelle, c.CMP_Description
FROM ASP_Missions m
JOIN ASP_Agents a ON a.AGN_Incr = m.MIS_AgentIncr
JOIN ASP_Competences c ON c.CMP_Code = m.MIS_CompetenceCode
WHERE m.MIS_Incr = ?'
);
$stmt->execute([$misIncr]);
$mission = $stmt->fetch();
if (!$mission) {
http_response_code(404);
echo json_encode(['error' => 'Mission introuvable']);
exit;
}
if ($mission['MIS_Statut'] === 'close') {
http_response_code(409);
echo json_encode(['error' => 'Cette mission est close — aucun nouvel échange possible.']);
exit;
}
// Vérification de la compétence (défense en profondeur : la création de la
// mission est censée déjà l'avoir garantie, mais jamais de confiance
// aveugle — voir principe déjà appliqué partout ailleurs dans ASPIC).
$stmt = $pdo->prepare(
'SELECT 1 FROM ASP_AgentsCompetences WHERE AGC_AgentIncr = ? AND AGC_CompetenceCode = ?'
);
$stmt->execute([$mission['MIS_AgentIncr'], $mission['MIS_CompetenceCode']]);
if (!$stmt->fetch()) {
http_response_code(500);
echo json_encode(['error' => "{$mission['AGN_Prenom']} n'a pas (ou plus) la compétence {$mission['MIS_CompetenceCode']} — mission incohérente."]);
exit;
}
// Site déjà existant pour cette mission (mise à jour) ou pas encore (création
// différée jusqu'au premier appel réel de ecrire_fichier_site — voir plus
// bas, on ne crée jamais une ligne ASP_SitesWeb pour une mission qui ne
// produit finalement rien).
$site = null;
if ($mission['MIS_SiteIncr']) {
$stmt = $pdo->prepare('SELECT * FROM ASP_SitesWeb WHERE SIT_Incr = ?');
$stmt->execute([$mission['MIS_SiteIncr']]);
$site = $stmt->fetch() ?: null;
}
$nomBaseTravail = (string) ($_SESSION['ASPIC_DB_NAME'] ?? 'defaut');
/**
* Chemin sécurisé d'un fichier à l'intérieur du dossier brouillon d'un site.
* Retourne null (jamais une exception) si le chemin proposé n'est pas
* acceptable — chaque appelant doit alors répondre par un tool_result en
* erreur, jamais silencieusement.
*/
function cheminSecuriseDansSite(string $racineSite, string $cheminRelatif): ?string
{
if ($cheminRelatif === '' || str_contains($cheminRelatif, "\0")) return null;
if (str_starts_with($cheminRelatif, '/') || str_starts_with($cheminRelatif, '\\')) return null;
if (str_contains($cheminRelatif, '..')) return null;
$extension = strtolower(pathinfo($cheminRelatif, PATHINFO_EXTENSION));
if (!in_array($extension, EXTENSIONS_AUTORISEES_SITE, true)) return null;
$cheminComplet = $racineSite . '/' . $cheminRelatif;
$dossierCible = dirname($cheminComplet);
if (!is_dir($dossierCible)) {
if (!@mkdir($dossierCible, 0777, true) && !is_dir($dossierCible)) return null;
@chmod($dossierCible, 0777);
}
$racineReelle = realpath($racineSite);
$dossierReel = realpath($dossierCible);
if ($racineReelle === false || $dossierReel === false) return null;
// Protection traversée de chemin : le dossier résolu doit rester SOUS la racine.
if (!str_starts_with($dossierReel . '/', $racineReelle . '/')) return null;
return $dossierReel . '/' . basename($cheminRelatif);
}
function slugify(string $texte): string
{
$texte = iconv('UTF-8', 'ASCII//TRANSLIT', $texte) ?: $texte;
$texte = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $texte));
return trim($texte, '-') ?: 'site';
}
/**
* Crée la ligne ASP_SitesWeb (et le dossier brouillon physique) au tout
* premier fichier réellement écrit par une mission de création — jamais à
* l'ouverture de la mission elle-même, pour ne pas laisser de site fantôme
* si la mission n'aboutit à rien.
*/
function creerSiteSiBesoin(PDO $pdo, array &$mission, string $nomBaseTravail): array
{
if ($mission['MIS_SiteIncr']) {
$stmt = $pdo->prepare('SELECT * FROM ASP_SitesWeb WHERE SIT_Incr = ?');
$stmt->execute([$mission['MIS_SiteIncr']]);
return $stmt->fetch();
}
$nomSite = $mission['MIS_Titre'] !== '' ? $mission['MIS_Titre'] : 'Site sans titre';
$stmt = $pdo->prepare(
"INSERT INTO ASP_SitesWeb (SIT_Nom, SIT_Statut, SIT_CheminDossier) VALUES (?, 'brouillon', '')"
);
$stmt->execute([$nomSite]);
$siteIncr = (int) $pdo->lastInsertId();
$cheminDossier = $nomBaseTravail . '/' . $siteIncr . '-' . slugify($nomSite);
$pdo->prepare('UPDATE ASP_SitesWeb SET SIT_CheminDossier = ? WHERE SIT_Incr = ?')
->execute([$cheminDossier, $siteIncr]);
$pdo->prepare('UPDATE ASP_Missions SET MIS_SiteIncr = ? WHERE MIS_Incr = ?')
->execute([$siteIncr, $mission['MIS_Incr']]);
$mission['MIS_SiteIncr'] = $siteIncr;
$dossierBrouillon = RACINE_BROUILLONS . '/' . $cheminDossier;
@mkdir($dossierBrouillon, 0777, true);
@chmod($dossierBrouillon, 0777);
return [
'SIT_Incr' => $siteIncr,
'SIT_Nom' => $nomSite,
'SIT_Statut' => 'brouillon',
'SIT_CheminDossier' => $cheminDossier,
];
}
/** Outils Anthropic disponibles pour une compétence — voir en-tête. */
function outilsDeLaCompetence(string $competenceCode): array
{
if ($competenceCode === 'SITE_WEB') {
return [
[
'name' => 'lister_fichiers_site',
'description' => "Liste les fichiers déjà écrits dans le site en cours de création/mise à jour. Renvoie une liste vide si rien n'a encore été créé.",
'input_schema' => ['type' => 'object', 'properties' => new stdClass()],
],
[
'name' => 'lire_fichier_site',
'description' => "Lit le contenu actuel d'un fichier déjà écrit dans le site.",
'input_schema' => [
'type' => 'object',
'properties' => ['chemin' => ['type' => 'string', 'description' => 'Chemin relatif, ex. "index.html" ou "css/style.css".']],
'required' => ['chemin'],
],
],
[
'name' => 'ecrire_fichier_site',
'description' => "Crée ou remplace un fichier du site. Extensions autorisées uniquement : html, css, js, json, svg (pas de PHP, pas d'exécutable).",
'input_schema' => [
'type' => 'object',
'properties' => [
'chemin' => ['type' => 'string', 'description' => 'Chemin relatif, ex. "index.html".'],
'contenu' => ['type' => 'string', 'description' => 'Contenu texte intégral du fichier.'],
],
'required' => ['chemin', 'contenu'],
],
],
];
}
return [];
}
/**
* Exécute un outil demandé par le modèle. Retourne
* [contenuTexteDuResultat, estUneErreur, ligneJournal].
* Ne lève jamais d'exception vers l'appelant : toute anomalie devient un
* tool_result en erreur, que le modèle peut lire et corriger lui-même au
* tour suivant — jamais un échec silencieux côté serveur.
*/
function executerOutil(string $nom, array $entree, PDO $pdo, array &$mission, string $nomBaseTravail): array
{
if ($nom === 'lister_fichiers_site') {
if (!$mission['MIS_SiteIncr']) {
return ["(Aucun fichier — le site n'a pas encore été créé.)", false, "lister_fichiers_site() -> vide (site pas encore créé)"];
}
$stmt = $pdo->prepare('SELECT SWF_CheminRelatif, SWF_Taille FROM ASP_SitesWebFichiers WHERE SWF_SiteIncr = ? ORDER BY SWF_CheminRelatif');
$stmt->execute([$mission['MIS_SiteIncr']]);
$fichiers = $stmt->fetchAll();
if (!$fichiers) {
return ["(Aucun fichier écrit pour l'instant.)", false, "lister_fichiers_site() -> vide"];
}
$texte = implode("\n", array_map(fn($f) => "{$f['SWF_CheminRelatif']} ({$f['SWF_Taille']} octets)", $fichiers));
return [$texte, false, "lister_fichiers_site() -> " . count($fichiers) . " fichier(s)"];
}
if ($nom === 'lire_fichier_site') {
$chemin = trim((string) ($entree['chemin'] ?? ''));
if (!$mission['MIS_SiteIncr']) {
return ["Le site n'existe pas encore, aucun fichier à lire.", true, "lire_fichier_site($chemin) -> échec (site inexistant)"];
}
$stmt = $pdo->prepare('SELECT SIT_CheminDossier FROM ASP_SitesWeb WHERE SIT_Incr = ?');
$stmt->execute([$mission['MIS_SiteIncr']]);
$site = $stmt->fetch();
$cheminAbsolu = cheminSecuriseDansSite(RACINE_BROUILLONS . '/' . $site['SIT_CheminDossier'], $chemin);
if ($cheminAbsolu === null || !is_file($cheminAbsolu)) {
return ["Fichier introuvable ou chemin non autorisé : $chemin", true, "lire_fichier_site($chemin) -> échec (introuvable/non autorisé)"];
}
return [file_get_contents($cheminAbsolu), false, "lire_fichier_site($chemin) -> ok"];
}
if ($nom === 'ecrire_fichier_site') {
$chemin = trim((string) ($entree['chemin'] ?? ''));
$contenu = (string) ($entree['contenu'] ?? '');
$site = creerSiteSiBesoin($pdo, $mission, $nomBaseTravail);
$racineSite = RACINE_BROUILLONS . '/' . $site['SIT_CheminDossier'];
@mkdir($racineSite, 0777, true);
@chmod($racineSite, 0777);
$cheminAbsolu = cheminSecuriseDansSite($racineSite, $chemin);
if ($cheminAbsolu === null) {
return ["Chemin refusé (extension non autorisée, ou en dehors du site) : $chemin", true, "ecrire_fichier_site($chemin) -> refusé"];
}
if (strlen($contenu) > 2_000_000) {
return ["Fichier trop volumineux (2 Mo max) : $chemin", true, "ecrire_fichier_site($chemin) -> refusé (trop volumineux)"];
}
file_put_contents($cheminAbsolu, $contenu);
@chmod($cheminAbsolu, 0666);
$taille = strlen($contenu);
$stmt = $pdo->prepare('SELECT SWF_Incr FROM ASP_SitesWebFichiers WHERE SWF_SiteIncr = ? AND SWF_CheminRelatif = ?');
$stmt->execute([$site['SIT_Incr'], $chemin]);
if ($ligne = $stmt->fetch()) {
$pdo->prepare('UPDATE ASP_SitesWebFichiers SET SWF_Taille = ?, SWF_DateModification = NOW() WHERE SWF_Incr = ?')
->execute([$taille, $ligne['SWF_Incr']]);
} else {
$pdo->prepare('INSERT INTO ASP_SitesWebFichiers (SWF_SiteIncr, SWF_CheminRelatif, SWF_Taille, SWF_DateModification) VALUES (?, ?, ?, NOW())')
->execute([$site['SIT_Incr'], $chemin, $taille]);
}
return ["Fichier écrit : $chemin ($taille octets)", false, "ecrire_fichier_site($chemin) -> ok ($taille octets)"];
}
return ["Outil inconnu : $nom", true, "$nom(?) -> outil inconnu"];
}
function appellerAnthropic(string $apiKey, string $workspaceId, string $promptSysteme, array $historique, array $outils): array
{
// system en cache_control (11 septembre 2026, voir journal_ia.php) : le
// prompt système d'une mission (identité + expérience + brief + liste
// d'outils) est identique à chaque tour de la boucle agentique ET à
// chaque nouveau message de la même mission — sans cache, il était
// refacturé en entier à CHAQUE appel, y compris les multiples
// aller-retours d'une seule boucle d'outils (jusqu'à 6 par message).
$corpsRequete = [
'model' => ANTHROPIC_MODEL,
'max_tokens' => ANTHROPIC_MAX_TOKENS,
'system' => [
['type' => 'text', 'text' => $promptSysteme, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => $historique,
];
if ($outils) {
$corpsRequete['tools'] = $outils;
}
$entetes = [
'Content-Type: application/json',
'anthropic-version: ' . ANTHROPIC_VERSION,
'X-Api-Key: ' . $apiKey,
];
if ($workspaceId) {
$entetes[] = 'anthropic-workspace-id: ' . $workspaceId;
}
$ch = curl_init(ANTHROPIC_API_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpsRequete),
CURLOPT_HTTPHEADER => $entetes,
CURLOPT_TIMEOUT => 90,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$reponseBrute = curl_exec($ch);
$erreurCurl = curl_error($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($reponseBrute === false) {
throw new RuntimeException("Appel à l'API Anthropic impossible : $erreurCurl");
}
$decode = json_decode($reponseBrute, true);
if ($codeHttp !== 200) {
$msg = $decode['error']['message'] ?? $reponseBrute;
throw new RuntimeException("L'API Anthropic a renvoyé une erreur (HTTP $codeHttp) : $msg");
}
return $decode;
}
// -------- Construction du prompt système --------
$prenom = $mission['AGN_Prenom'] ?: 'ce collaborateur';
$identite = trim((string) ($mission['AGN_Identite'] ?? ''));
$blocIdentite = $identite !== '' ? $identite : "(Aucune identité détaillée écrite pour {$prenom}.)";
$experience = trim((string) ($mission['AGN_Experience'] ?? ''));
$blocExperience = $experience !== ''
? "\n\nCe que tu retiens de vos échanges précédents avec {$patron} :\n\n{$experience}"
: '';
$competencesAgent = trim((string) ($mission['AGN_Competences'] ?? ''));
$blocCompetences = $competencesAgent !== ''
? "\n\nTes compétences déclarées :\n\n{$competencesAgent}"
: '';
$outils = outilsDeLaCompetence($mission['MIS_CompetenceCode']);
$listeOutils = $outils
? implode("\n", array_map(fn($o) => "- {$o['name']} : {$o['description']}", $outils))
: "(aucun outil disponible pour cette compétence)";
$contexteSite = $site
? "Tu travailles sur un site déjà existant (\"{$site['SIT_Nom']}\", statut : {$site['SIT_Statut']}) — utilise lister_fichiers_site()/lire_fichier_site() avant de modifier quoi que ce soit à l'aveugle."
: "Aucun site n'existe encore pour cette mission — le premier appel à ecrire_fichier_site() le créera automatiquement.";
// Texte sorti du code le 11 septembre 2026 — voir moteur_prompts.php pour le
// texte par défaut/la table ASP_Prompts, bo_prompts.html pour l'édition.
try {
$promptSysteme = chargerPromptSysteme($pdo, 'MISSION_AGENT', [
'{{PRENOM}}' => $prenom,
'{{PATRON}}' => $patron,
'{{BLOC_IDENTITE}}' => $blocIdentite,
'{{BLOC_EXPERIENCE}}' => $blocExperience,
'{{BLOC_COMPETENCES}}' => $blocCompetences,
'{{TITRE_MISSION}}' => $mission['MIS_Titre'],
'{{OBJECTIF}}' => $mission['MIS_Objectif'],
'{{COMPETENCE_LIBELLE}}' => $mission['CMP_Libelle'],
'{{COMPETENCE_DESCRIPTION}}' => $mission['CMP_Description'],
'{{LISTE_OUTILS}}' => $listeOutils,
'{{CONTEXTE_SITE}}' => $contexteSite,
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Prompt système indisponible : ' . $e->getMessage()]);
exit;
}
// -------- Historique --------
$historique = [];
if (!empty($mission['MIS_HistoriqueJson'])) {
$historique = json_decode($mission['MIS_HistoriqueJson'], true) ?: [];
}
$historique[] = ['role' => 'user', 'content' => [['type' => 'text', 'text' => $message]]];
// -------- Boucle agentique --------
$texteFinal = '';
$outilsUtilises = [];
$lignesJournal = [];
$limiteAtteinte = false;
try {
for ($tour = 0; $tour < MAX_TOURS_OUTILS; $tour++) {
$reponseApi = appellerAnthropic($apiKey, $workspaceId, $promptSysteme, $historique, $outils);
// Chaque tour de la boucle est un appel facturé séparément (11
// septembre 2026, voir journal_ia.php) — une seule mission "écris-moi
// un site" peut déclencher jusqu'à MAX_TOURS_OUTILS appels réels pour
// un seul message de Jean-Marc, d'où l'importance de tous les tracer,
// pas seulement le dernier.
journaliserAppelIA($pdo, 'mission_agent', $mission['MIS_AgentIncr'], $misIncr, ANTHROPIC_MODEL, $reponseApi['usage'] ?? []);
$blocs = $reponseApi['content'] ?? [];
$historique[] = ['role' => 'assistant', 'content' => $blocs];
$texteTour = '';
$appelsOutils = [];
foreach ($blocs as $bloc) {
if (($bloc['type'] ?? '') === 'text') $texteTour .= $bloc['text'];
if (($bloc['type'] ?? '') === 'tool_use') $appelsOutils[] = $bloc;
}
if (($reponseApi['stop_reason'] ?? '') !== 'tool_use' || !$appelsOutils) {
$texteFinal = $texteTour;
break;
}
$resultatsOutils = [];
foreach ($appelsOutils as $appel) {
[$contenuResultat, $estErreur, $ligneJournal] = executerOutil(
$appel['name'], $appel['input'] ?? [], $pdo, $mission, $nomBaseTravail
);
$resultatsOutils[] = [
'type' => 'tool_result',
'tool_use_id' => $appel['id'],
'content' => $contenuResultat,
'is_error' => $estErreur,
];
$outilsUtilises[] = ['nom' => $appel['name'], 'resultat' => $contenuResultat, 'erreur' => $estErreur];
$lignesJournal[] = $ligneJournal;
}
$historique[] = ['role' => 'user', 'content' => $resultatsOutils];
if ($tour === MAX_TOURS_OUTILS - 1) {
$limiteAtteinte = true;
}
}
} catch (RuntimeException $e) {
http_response_code(502);
echo json_encode(['error' => $e->getMessage()]);
exit;
}
if ($limiteAtteinte && $texteFinal === '') {
$texteFinal = "(Je m'arrête ici pour cette fois — plusieurs actions d'affilée ont été nécessaires, dis-moi de continuer si besoin.)";
$lignesJournal[] = "[limite] plafond de $tour tours outils atteint sans réponse finale — jamais silencieux";
}
// -------- Persistance --------
$hordate = date('d/m/Y H:i');
$nouveauJournal = "\n--- $hordate ---\n[{$patron}] $message\n";
foreach ($lignesJournal as $l) {
$nouveauJournal .= " $l\n";
}
$nouveauJournal .= "[{$prenom}] $texteFinal\n";
$pdo->prepare('UPDATE ASP_Missions SET MIS_HistoriqueJson = ?, MIS_Journal = CONCAT(COALESCE(MIS_Journal, \'\'), ?) WHERE MIS_Incr = ?')
->execute([json_encode($historique), $nouveauJournal, $misIncr]);
echo json_encode(['reponse' => $texteFinal, 'outils' => $outilsUtilises]);
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASPIC — Test Missions / Compétences</title>
<style>
/* mission_test.html — 11 septembre 2026
Outil de mise au point interne (PAS un écran utilisateur), même esprit
que contact_ia_test.html : look "terminal", pas de polish. Sert à tester
mission_agent.php (dialogue + outils réels) et sites_web.php
(publication) avant toute intégration dans ecran_fond.html — le classeur
"Missions" du bureau n'a encore aucun écran réel (voir journal, section
19), inutile de construire l'écran final avant que le mécanisme lui-même
soit validé en conditions réelles. */
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:#111;color:#ddd;
font-family:"Segoe UI",Arial,sans-serif;}
#page{max-width:820px;margin:0 auto;padding:20px 16px 60px;}
h1{font-size:16px;color:#888;font-weight:normal;margin:0 0 4px;}
.sous-titre{font-size:12px;color:#666;margin:0 0 20px;}
section{background:#1a1a1a;border:1px solid #333;border-radius:6px;
padding:14px 16px;margin-bottom:16px;}
section h2{font-size:12px;color:#888;text-transform:uppercase;
letter-spacing:.05em;font-weight:normal;margin:0 0 10px;}
label{display:block;font-size:11px;color:#999;margin:8px 0 3px;}
input,textarea,select{width:100%;font:inherit;font-size:13px;
background:#0d0d0d;color:#eee;border:1px solid #444;border-radius:4px;
padding:6px 8px;}
textarea{min-height:60px;resize:vertical;font-family:inherit;}
button{font:inherit;font-size:13px;background:#2a4d2a;color:#bdf7bd;
border:1px solid #3a6d3a;border-radius:5px;padding:7px 16px;
cursor:pointer;margin-top:8px;}
button:disabled{opacity:.4;cursor:default;}
button.secondaire{background:#1a1a1a;color:#ccc;border-color:#444;}
.liste-missions .ligne, .liste-sites .ligne{padding:8px;border-bottom:1px solid #262626;
font-size:13px;cursor:pointer;display:flex;justify-content:space-between;gap:10px;}
.liste-missions .ligne:hover, .liste-sites .ligne:hover{background:#222;}
.liste-missions .ligne.actif{background:#233;}
.badge{font-size:11px;padding:2px 7px;border-radius:10px;background:#333;color:#aaa;}
.badge.ouverte{background:#2a4d2a;color:#bdf7bd;}
.badge.close{background:#4d2a2a;color:#f7bdbd;}
.badge.brouillon{background:#4d3a2a;color:#f7d7bd;}
.badge.publie{background:#2a3d4d;color:#bde0f7;}
#chat{min-height:200px;max-height:400px;overflow-y:auto;background:#000;
border:1px solid #333;border-radius:6px;padding:10px;margin-bottom:8px;}
.bulle{max-width:80%;padding:8px 12px;border-radius:8px;margin:6px 0;
font-size:13px;line-height:1.5;white-space:pre-wrap;}
.bulle.moi{background:#264d73;color:#fff;margin-left:auto;}
.bulle.collab{background:#333;color:#eee;}
.bulle.systeme{background:transparent;color:#7a7;font-size:11px;font-style:italic;}
.fichiers-site{font-size:12px;color:#999;margin-top:4px;}
#zone-saisie{display:flex;gap:8px;}
#zone-saisie textarea{flex:1;}
</style>
</head>
<body>
<div id="page">
<h1>ASPIC — Test Missions / Compétences</h1>
<p class="sous-titre">Outil interne — pas un écran utilisateur final.</p>
<section>
<h2>Nouvelle mission — Sophia / Créer et gérer un site web vitrine</h2>
<label>Titre</label>
<input id="nvTitre" placeholder="ex. Site vitrine Mevelia Solutions">
<label>Objectif (brief donné à Sophia)</label>
<textarea id="nvObjectif" placeholder="Décris ce que le site doit contenir, le ton, les pages attendues..."></textarea>
<button id="btnCreerMission">Ouvrir la mission</button>
<span id="msgCreation" style="margin-left:10px;font-size:12px;"></span>
</section>
<section>
<h2>Missions</h2>
<div id="listeMissions" class="liste-missions"></div>
</section>
<section id="blocChat" style="display:none;">
<h2 id="titreMissionActive"></h2>
<div id="chat"></div>
<div id="zone-saisie">
<textarea id="saisieMessage" placeholder="Écris à Sophia..."></textarea>
<button id="btnEnvoyer">Envoyer</button>
</div>
</section>
<section>
<h2>Sites</h2>
<div id="listeSites" class="liste-sites"></div>
</section>
</div>
<script>
const AGENT_SOPHIA = 13;
const COMPETENCE_SITE_WEB = 'SITE_WEB';
let missionActive = null;
function badge(texte, classe) {
return `<span class="badge ${classe}">${texte}</span>`;
}
async function chargerMissions() {
const r = await fetch('missions.php');
const data = await r.json();
const conteneur = document.getElementById('listeMissions');
conteneur.innerHTML = '';
(data.missions || []).forEach(m => {
const div = document.createElement('div');
div.className = 'ligne' + (missionActive === m.MIS_Incr ? ' actif' : '');
div.innerHTML = `<span>#${m.MIS_Incr} — ${m.MIS_Titre} (${m.AGN_Prenom})</span>${badge(m.MIS_Statut, m.MIS_Statut)}`;
div.addEventListener('click', () => ouvrirMission(m.MIS_Incr, m.MIS_Titre));
conteneur.appendChild(div);
});
}
async function chargerSites() {
const r = await fetch('sites_web.php');
const data = await r.json();
const conteneur = document.getElementById('listeSites');
conteneur.innerHTML = '';
(data.sites || []).forEach(s => {
const div = document.createElement('div');
div.className = 'ligne';
const fichiers = (s.fichiers || []).map(f => f.SWF_CheminRelatif).join(', ') || '(aucun fichier)';
div.innerHTML = `<div>
<div>#${s.SIT_Incr} — ${s.SIT_Nom} ${badge(s.SIT_Statut, s.SIT_Statut)}</div>
<div class="fichiers-site">${fichiers}</div>
</div>`;
if (s.SIT_Statut === 'brouillon' && (s.fichiers || []).length) {
const btn = document.createElement('button');
btn.className = 'secondaire';
btn.textContent = 'Publier';
btn.addEventListener('click', async (e) => {
e.stopPropagation();
btn.disabled = true;
const rp = await fetch('sites_web.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({action:'publier', SIT_Incr: s.SIT_Incr})
});
const dp = await rp.json();
alert(rp.ok ? 'Publié : ' + dp.chemin_publie : (dp.error || 'Erreur'));
chargerSites();
});
div.appendChild(btn);
}
conteneur.appendChild(div);
});
}
function ajouterBulle(role, texte) {
const bulle = document.createElement('div');
bulle.className = 'bulle ' + role;
bulle.textContent = texte;
document.getElementById('chat').appendChild(bulle);
document.getElementById('chat').scrollTop = 999999;
}
function ouvrirMission(misIncr, titre) {
missionActive = misIncr;
document.getElementById('blocChat').style.display = 'block';
document.getElementById('titreMissionActive').textContent = `Mission #${misIncr} — ${titre}`;
document.getElementById('chat').innerHTML = '';
ajouterBulle('systeme', 'Mission ouverte — écris ton premier message.');
chargerMissions();
}
document.getElementById('btnCreerMission').addEventListener('click', async () => {
const titre = document.getElementById('nvTitre').value.trim();
const objectif = document.getElementById('nvObjectif').value.trim();
const msg = document.getElementById('msgCreation');
if (!titre || !objectif) { msg.textContent = 'Titre et objectif obligatoires.'; return; }
const r = await fetch('missions.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({MIS_AgentIncr: AGENT_SOPHIA, MIS_CompetenceCode: COMPETENCE_SITE_WEB, MIS_Titre: titre, MIS_Objectif: objectif})
});
const data = await r.json();
if (!r.ok) { msg.textContent = data.error || 'Erreur'; return; }
msg.textContent = 'Mission créée.';
document.getElementById('nvTitre').value = '';
document.getElementById('nvObjectif').value = '';
await chargerMissions();
ouvrirMission(data.MIS_Incr, titre);
});
document.getElementById('btnEnvoyer').addEventListener('click', async () => {
const zone = document.getElementById('saisieMessage');
const texte = zone.value.trim();
if (!texte || !missionActive) return;
zone.value = '';
ajouterBulle('moi', texte);
const attente = document.createElement('div');
attente.className = 'bulle systeme';
attente.textContent = 'Sophia réfléchit...';
document.getElementById('chat').appendChild(attente);
try {
const r = await fetch('mission_agent.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({MIS_Incr: missionActive, message: texte})
});
const data = await r.json();
attente.remove();
if (!r.ok) { ajouterBulle('systeme', 'Erreur : ' + (data.error || r.status)); return; }
(data.outils || []).forEach(o => {
ajouterBulle('systeme', `[outil] ${o.nom} → ${o.erreur ? 'échec' : 'ok'} : ${o.resultat}`);
});
ajouterBulle('collab', data.reponse);
chargerSites();
} catch (e) {
attente.remove();
ajouterBulle('systeme', 'Erreur réseau');
}
});
chargerMissions();
chargerSites();
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* ASPIC — missions.php
*
* Endpoint interne (11 septembre 2026, chantier Compétences/Missions) —
* création/liste des missions. Volontairement séparé de mission_agent.php
* (qui ne fait que dialoguer sur une mission déjà ouverte) : ouvrir une
* mission est un acte de gestion (qui, quoi, avec quelle compétence), pas un
* tour de dialogue.
*
* GET : liste les missions (agent, compétence, site éventuel, statut).
* POST { "MIS_AgentIncr": 13, "MIS_CompetenceCode": "SITE_WEB",
* "MIS_Titre": "...", "MIS_Objectif": "..." }
* Vérifie que le collaborateur possède bien la compétence demandée avant
* toute création (ASP_AgentsCompetences) — refus explicite sinon, jamais
* une mission incohérente créée silencieusement.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$missions = $pdo->query(
"SELECT m.MIS_Incr, m.MIS_Titre, m.MIS_Objectif, m.MIS_Statut, m.MIS_DateOuverture, m.MIS_DateCloture,
a.AGN_Prenom, c.CMP_Libelle, m.MIS_SiteIncr, s.SIT_Nom, s.SIT_Statut AS SIT_Statut
FROM ASP_Missions m
JOIN ASP_Agents a ON a.AGN_Incr = m.MIS_AgentIncr
JOIN ASP_Competences c ON c.CMP_Code = m.MIS_CompetenceCode
LEFT JOIN ASP_SitesWeb s ON s.SIT_Incr = m.MIS_SiteIncr
ORDER BY m.MIS_DateOuverture DESC"
)->fetchAll();
echo json_encode(['missions' => $missions]);
exit;
}
if ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$agentIncr = (int) ($data['MIS_AgentIncr'] ?? 0);
$competenceCode = trim((string) ($data['MIS_CompetenceCode'] ?? ''));
$titre = trim((string) ($data['MIS_Titre'] ?? ''));
$objectif = trim((string) ($data['MIS_Objectif'] ?? ''));
if ($agentIncr <= 0 || $competenceCode === '' || $titre === '' || $objectif === '') {
http_response_code(400);
echo json_encode(['error' => 'MIS_AgentIncr, MIS_CompetenceCode, MIS_Titre et MIS_Objectif sont obligatoires.']);
exit;
}
$stmt = $pdo->prepare('SELECT 1 FROM ASP_AgentsCompetences WHERE AGC_AgentIncr = ? AND AGC_CompetenceCode = ?');
$stmt->execute([$agentIncr, $competenceCode]);
if (!$stmt->fetch()) {
http_response_code(400);
echo json_encode(['error' => "Ce collaborateur n'a pas la compétence $competenceCode — mission refusée."]);
exit;
}
$stmt = $pdo->prepare(
"INSERT INTO ASP_Missions (MIS_AgentIncr, MIS_CompetenceCode, MIS_Titre, MIS_Objectif, MIS_Statut)
VALUES (?, ?, ?, ?, 'ouverte')"
);
$stmt->execute([$agentIncr, $competenceCode, $titre, $objectif]);
echo json_encode(['success' => true, 'MIS_Incr' => (int) $pdo->lastInsertId()]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+373
View File
@@ -0,0 +1,373 @@
<?php
/**
* ASPIC — moteur_consignes.php
*
* Moteur générique du chantier des consignes (6 septembre 2026, voir
* Réflexions stratégiques). Lit ASP_Consignes/ASP_Fonctions à l'exécution et
* applique ce qu'il y trouve — l'objectif explicite de Jean-Marc étant que
* l'ajout d'une ligne dans ASP_Consignes change le comportement réel de
* l'application sans qu'aucun fichier PHP/JS ne soit retouché, sauf si une
* fonction nouvelle doit être écrite dans consignes_fonctions.php (ou en cas
* de bug). Ce fichier ne doit donc plus jamais contenir la moindre référence
* à un champ ou une consigne précis — tout vient de la base.
*
* Portée de cette première version, volontairement limitée :
* - CNS_Champ NULL (consigne transverse à la fiche entière, ex. futur
* contrôle de doublon) n'est PAS encore géré ; ces lignes sont ignorées
* pour l'instant, à reprendre quand un vrai cas se présentera. En revanche
* CNS_Champ PEUT désormais contenir PLUSIEURS noms de champs séparés par
* une virgule (ex. "CNT_AdrVille,CNT_AdrCP") — extension du 7 septembre
* 2026 (soir), nécessaire pour une consigne dont la réponse dépend de
* plusieurs champs à la fois (ex. déduire le code postal à partir de la
* ville, voir consigne_completer_code_postal() dans
* consignes_fonctions.php). Une consigne à un seul champ garde exactement
* le même contrat qu'avant (fonction appelée avec une simple chaîne) ; une
* consigne à plusieurs champs appelle sa fonction avec un tableau associatif
* [nom_du_champ => valeur] et attend en retour, pour un Traitement, ce même
* type de tableau (valeurs mises à jour) — voir les deux boucles ci-dessous.
* - CNS_Moment n'est PAS distingué ici : temps réel (Saisie) abandonné pour
* cette version (décision de Jean-Marc, 6 septembre 2026 — priorité à la
* robustesse plutôt qu'au retour immédiat à l'écran). Toute consigne
* active s'applique donc au moment de l'enregistrement, qu'elle porte
* Saisie ou Validation.
* - Un seul message d'erreur retourné (le premier test en échec) — pas
* d'accumulation de plusieurs erreurs à la fois pour ce premier chantier.
*
* CNS_Portee : 'Mise à jour' est traité comme un mot-clé signifiant "les deux
* moments" (Création et Modification), à côté de valeurs plus précises
* ('Création' ou 'Modification' seules seraient possibles plus tard pour une
* règle d'immuabilité — voir Réflexions stratégiques).
*
* CNS_Agent (7 septembre 2026) : nom libre (varchar, pas de clé étrangère
* vers ASP_Agents) permettant de rattacher une consigne à un agent — utile
* en particulier pour les prochaines consignes de test qui simuleront des
* comportements par agent. Champ facultatif, remonté via le paramètre par
* référence $agentEchec plutôt qu'en changeant le type de retour de la
* fonction (qui reste un simple string|null, pour ne pas casser le contrat
* déjà en place) : seul contacts.php lit ce paramètre pour l'ajouter à sa
* réponse JSON, le front-end (afficherToast) l'affiche en italique sous le
* message quand il est présent.
*
* Journalisation des Traitements IA (7 septembre 2026, soir) : après le
* premier essai réel de consigne_completer_code_postal(), Jean-Marc a demandé
* de pouvoir tracer QUAND et POURQUOI l'IA a été sollicitée (bilan de
* consommation de tokens, entre autres). Un Traitement à PLUSIEURS champs
* (voir ci-dessus) peut donc désormais glisser, dans le tableau qu'il
* retourne, une clé réservée "_realisation" — jamais un vrai nom de champ
* ASPIC (tous commencent par un préfixe majuscule type "CNT_") donc aucun
* risque de collision. Cette clé est retirée par le moteur avant d'écrire le
* reste dans $data (voir boucle des Traitements ci-dessous), et collectée
* dans le nouveau paramètre par référence $journalisations. Volontairement
* PAS d'écriture directe dans ASP_Realisations ici : ce fichier n'a pas
* connaissance de CNT_Incr (inexistant tant que l'INSERT n'a pas eu lieu, en
* création) — c'est contacts.php qui journalise réellement, une fois
* l'identifiant du contact connu (voir ce fichier).
*
* Badge IA générique côté écran (7 septembre 2026, soir, suite) : le premier
* jet de ce badge reposait sur une condition écrite en dur dans
* liste_contacts.html (ville renseignée, code postal vide) — Jean-Marc a
* fait remarquer à juste titre que c'est exactement la même condition que la
* garde en tête de consigne_completer_code_postal(), et que dupliquer cette
* condition côté écran allait à l'encontre du chantier des consignes (une
* nouvelle consigne IA aurait demandé de retoucher le JS). Nouvelle
* convention à la place : une fonction de Traitement porteuse de
* FON_VisuelAttenteIA = 'O' (ASP_Fonctions — propriété de la FONCTION,
* remarque de Jean-Marc : c'est elle qui interroge une IA, pas la consigne
* qui l'utilise) DOIT avoir un jumeau nommé "<FON_NomPHP>_seraitDeclenchee"
* dans consignes_fonctions.php, signature (array $valeurs): bool — jamais
* d'appel réseau, juste la même garde que la vraie fonction (qui l'appelle en
* premier, voir consigne_completer_code_postal()) : la condition n'existe
* ainsi qu'à un seul endroit. Voir auMoinsUneConsigneIaSeraitDeclenchee()
* ci-dessous, utilisée par le nouveau point d'accès consignes_apercu.php —
* l'écran l'interroge une fois, juste avant l'enregistrement réel, pour
* savoir s'il doit afficher le badge pendant l'attente qui suit.
*
* Hiérarchisation Traitements de cohérence / Traitements IA (7 septembre
* 2026, nuit) : test réel de Jean-Marc (mail invalide + ville sans code
* postal) — l'ordre d'origine (TOUS les Traitements, puis TOUS les Tests)
* faisait sonner l'IA (coût réel, appel réseau) AVANT que le Test MAIL_VALIDE
* échoue et bloque l'enregistrement ; le contact n'étant jamais créé, cet
* appel IA n'était même pas journalisable (pas de CNT_Incr) — perdu, coût et
* trace. Pire : en resaisissant pour corriger le mail, un DEUXIÈME appel IA
* partait pour le même contact. Jean-Marc : "il faut qu'on hiérarchise les
* traitements : d'abord les traitements de cohérence, après les traitements
* par IA". Nouvel ordre d'exécution, réutilisant FON_VisuelAttenteIA comme
* seule frontière (déjà la bonne propriété : "cette fonction interroge un
* service externe") :
* 1. Traitements de cohérence (FON_VisuelAttenteIA != 'O') — silencieux,
* gratuits, locaux, comme avant.
* 2. Tests, sur les valeurs déjà mises en cohérence ci-dessus — premier
* échec = arrêt immédiat, AVANT tout appel IA.
* 3. Traitements IA (FON_VisuelAttenteIA = 'O') — atteints seulement si
* tous les Tests sont passés : un enregistrement voué à l'échec ne
* déclenche donc plus jamais d'appel IA.
* Limite assumée, à revoir le jour où un vrai cas se présentera : un Test qui
* porterait sur un champ produit par un Traitement IA (ex. valider le format
* d'un code postal déduit par l'IA) verrait la valeur D'AVANT cet appel IA,
* puisque les Tests s'exécutent avant l'étape 3 — aucune consigne actuelle
* n'est dans ce cas.
*
* Test à un champ avec accès base (8 septembre 2026) : première consigne de
* cette nature (unicité du mail/portable, voir consigne_mail_unique() et
* consigne_portable_unique() dans consignes_fonctions.php). Le contrat de
* base d'un Test à un champ reste (string $valeur): bool (voir en-tête de
* consignes_fonctions.php), mais le moteur appelle désormais TOUJOURS ces
* fonctions avec deux arguments supplémentaires, $pdo et $idExclusion — sans
* effet sur les fonctions existantes qui ne déclarent qu'un seul paramètre
* (PHP ignore silencieusement les arguments en trop), et exploitable par une
* nouvelle fonction qui déclare ces deux paramètres en plus. $idExclusion est
* l'identifiant (ex. CNT_Incr) de la fiche en cours de modification — null en
* Création (rien à exclure), ou en Modification si l'appelant ne le fournit
* pas — pour qu'une consigne d'unicité ne se bloque jamais elle-même en
* retrouvant la propre valeur déjà enregistrée de la fiche qu'on modifie.
* Fourni par l'appelant (voir $idExclusion du paramètre d'appliquerConsignes()
* ci-dessous) : ce fichier n'a connaissance d'aucun identifiant métier propre
* à un contexte précis, volontairement générique.
*/
/**
* Charge les consignes actives d'un contexte/portée donnés, avec leur
* fonction associée — factorisé ici (7 septembre 2026, soir) car utilisé à
* la fois par appliquerConsignes() ci-dessous et par
* auMoinsUneConsigneIaSeraitDeclenchee() : une seule requête à faire évoluer
* si la jointure ASP_Consignes/ASP_Fonctions change un jour.
*/
function chargerConsignesActives(PDO $pdo, string $contexte, string $portee): array
{
$sql = "SELECT c.CNS_Champ, c.CNS_Nature, c.CNS_MessageErreur, c.CNS_Agent,
f.FON_NomPHP, f.FON_VisuelAttenteIA
FROM ASP_Consignes c
LEFT JOIN ASP_Fonctions f
ON f.FON_Nom = c.CNS_Fonction AND f.FON_Active = 'O'
WHERE c.CNS_Contexte = ?
AND c.CNS_Active = 'O'
AND c.CNS_Champ IS NOT NULL
AND (c.CNS_Portee = 'Mise à jour' OR FIND_IN_SET(?, c.CNS_Portee))
ORDER BY c.CNS_Incr";
$stmt = $pdo->prepare($sql);
$stmt->execute([$contexte, $portee]);
return $stmt->fetchAll();
}
/**
* Est-ce qu'au moins une consigne de Traitement porteuse de
* FON_VisuelAttenteIA = 'O' se déclencherait réellement pour ces données ?
* Jamais d'appel réseau ici (voir commentaire d'en-tête) : chaque fonction
* concernée est interrogée via son jumeau "_seraitDeclenchee", pas la
* fonction réelle. Utilisée par consignes_apercu.php pour piloter le badge
* IA générique côté écran — un simple booléen, jamais le détail de quelle
* consigne précisément (le badge reste volontairement générique, voir
* réflexions avec Jean-Marc : pas de couleur/identité par agent ici).
*/
function auMoinsUneConsigneIaSeraitDeclenchee(PDO $pdo, string $contexte, string $portee, array $data): bool
{
$consignes = chargerConsignesActives($pdo, $contexte, $portee);
foreach ($consignes as $c) {
if ($c['CNS_Nature'] !== 'Traitement') continue;
if (($c['FON_VisuelAttenteIA'] ?? 'N') !== 'O') continue;
if (!$c['FON_NomPHP']) continue;
$nomApercu = $c['FON_NomPHP'] . '_seraitDeclenchee';
if (!function_exists($nomApercu)) continue;
$champs = array_map('trim', explode(',', $c['CNS_Champ']));
$valeurs = [];
foreach ($champs as $champ) {
$valeurs[$champ] = (string)($data[$champ] ?? '');
}
if (call_user_func($nomApercu, $valeurs)) {
return true;
}
}
return false;
}
/**
* Exécute une liste de consignes de Traitement (déjà filtrée par l'appelant)
* sur $data, dans l'ordre reçu. Factorisé (7 septembre 2026, nuit) car
* désormais appelé deux fois par appliquerConsignes() — une fois pour les
* Traitements de cohérence, une fois pour les Traitements IA — avec
* exactement la même mécanique à champ unique/multiple et la même collecte
* de "_realisation" dans les deux cas.
*
* Provenance de l'agent journalisé (7 septembre 2026, nuit — suite) : Jean-
* Marc a repéré que consigne_completer_code_postal() inventait elle-même son
* agent ("Mat", en dur dans le code, sans lien avec aucune table) et a fait
* remarquer que CNS_Agent existe déjà dans ASP_Consignes — exactement ce qui
* alimente déjà $agentEchec pour un Test en échec (voir plus bas). Même
* principe appliqué ici : l'agent réellement journalisé est TOUJOURS celui
* de CNS_Agent quand la consigne en porte un (nom en clair, tronqué à 15
* caractères pour tenir dans REA_CodeUser — voir suggestion SQL associée),
* jamais celui que la fonction aurait pu proposer elle-même. Une fonction de
* Traitement n'a donc plus aucune raison d'inventer un nom d'agent dans son
* "_realisation" — au mieux une valeur de repli si jamais CNS_Agent était
* laissé vide par erreur.
*
* Clé réservée "_avertissement" (7 septembre 2026, nuit — suite) : Jean-Marc,
* testant "Paris" (ville sans code postal unique, découpée en
* arrondissements), a remarqué qu'un appel IA infructueux ne laissait AUCUNE
* trace, ni à l'écran ni dans les réalisations — "il y a un appel fait à
* l'IA et pas de trace dans les réalisations, ce qui n'est pas normal".
* "_realisation" couvre déjà l'historique (voir consigne_completer_code_postal(),
* désormais journalisée aussi en cas d'échec) ; "_avertissement" couvre
* l'AUTRE moitié du problème, le retour immédiat à l'écran — sans lui,
* l'utilisateur ne voit rien se passer alors que l'IA a bien réfléchi et a
* sciemment renoncé. Contrat : une simple chaîne (le message tel quel,
* déjà formulé pour l'utilisateur), collectée ici de la même façon que
* "_realisation", jamais écrite dans $data.
*/
function executerTraitements(array $consignesTraitement, array &$data, array &$journalisations, array &$avertissements): void
{
foreach ($consignesTraitement as $c) {
if (!$c['FON_NomPHP'] || !function_exists($c['FON_NomPHP'])) continue;
$champs = array_map('trim', explode(',', $c['CNS_Champ']));
if (count($champs) === 1) {
// Contrat historique, inchangé : une chaîne en entrée, une
// chaîne en sortie.
$valeurActuelle = (string)($data[$champs[0]] ?? '');
$data[$champs[0]] = call_user_func($c['FON_NomPHP'], $valeurActuelle);
} else {
// Consigne à plusieurs champs (7 septembre 2026, voir
// commentaire d'en-tête) : un tableau associatif dans les deux
// sens, pour que la fonction sache quel champ correspond à
// quelle valeur sans dépendre de l'ordre dans CNS_Champ.
$valeursActuelles = [];
foreach ($champs as $champ) {
$valeursActuelles[$champ] = (string)($data[$champ] ?? '');
}
$valeursMisesAJour = call_user_func($c['FON_NomPHP'], $valeursActuelles);
// Clé réservée "_realisation" (7 septembre 2026, voir commentaire
// d'en-tête) : jamais écrite dans $data, juste collectée à part.
if (isset($valeursMisesAJour['_realisation'])) {
$realisation = $valeursMisesAJour['_realisation'];
// L'agent vient de la consigne (CNS_Agent), jamais de la
// fonction — voir commentaire ci-dessus. Repli sur ce que la
// fonction avait proposé, puis sur 'IA' générique, seulement
// si CNS_Agent est vide (ne devrait plus arriver une fois la
// consigne correctement renseignée).
$agentConsigne = trim((string)($c['CNS_Agent'] ?? ''));
$realisation['agent'] = $agentConsigne !== ''
? mb_substr($agentConsigne, 0, 15, 'UTF-8')
: ($realisation['agent'] ?? 'IA');
$journalisations[] = $realisation;
unset($valeursMisesAJour['_realisation']);
}
// Clé réservée "_avertissement" (voir commentaire ci-dessus) :
// même principe que "_realisation", jamais écrite dans $data.
if (isset($valeursMisesAJour['_avertissement'])) {
$avertissements[] = (string)$valeursMisesAJour['_avertissement'];
unset($valeursMisesAJour['_avertissement']);
}
foreach ($valeursMisesAJour as $champ => $valeur) {
$data[$champ] = $valeur;
}
}
}
}
/**
* @param PDO $pdo Connexion déjà ouverte (voir db.php).
* @param string $contexte Ex. 'Contacts' (ASP_Consignes.CNS_Contexte).
* @param string $portee 'Création' ou 'Modification'.
* @param array $data Données du formulaire, par référence —
* les traitements silencieux (ex.
* majuscules) modifient directement ce
* tableau.
* @param string|null $agentEchec Sortie par référence : CNS_Agent de la
* consigne dont le test a échoué (ou null
* si tout est passé, ou si la consigne en
* échec n'a pas d'agent renseigné).
* @param array|null $journalisations Sortie par référence : liste des
* entrées ['action'=>ACT_Code,
* 'agent'=>CNS_Agent de la consigne
* (nom en clair, tronqué à 15 caractères
* — voir executerTraitements() ci-dessus),
* 'commentaire'=>...] glissées par les
* Traitements à plusieurs champs qui l'ont
* demandé (voir commentaire d'en-tête) —
* tableau vide si aucun (jamais null en
* sortie, seul le type d'entrée est
* nullable — voir note ci-dessous). À
* journaliser dans ASP_Realisations par
* l'appelant.
* @param array|null $avertissements Sortie par référence : messages à
* afficher immédiatement à l'écran
* (voir "_avertissement" dans
* executerTraitements() ci-dessus) —
* tableau vide si aucun. À afficher par
* l'appelant (ex. un toast par message),
* indépendamment de $journalisations qui
* couvre l'historique, pas l'écran.
* @param int|null $idExclusion Identifiant de la fiche en cours de
* modification (ex. CNT_Incr), à exclure
* d'une éventuelle vérification
* d'unicité — null en Création, ou si
* aucune consigne du contexte n'en a
* besoin (8 septembre 2026, voir
* commentaire d'en-tête). Simplement
* retransmis tel quel aux fonctions de
* Test à un champ qui le demandent.
* @return string|null Message d'erreur du premier test en
* échec (déjà le texte à renvoyer tel
* quel au front-end), ou null si tout est
* passé.
*/
function appliquerConsignes(PDO $pdo, string $contexte, string $portee, array &$data, ?string &$agentEchec = null, ?array &$journalisations = null, ?array &$avertissements = null, ?int $idExclusion = null): ?string
{
// Type nullable en entrée (comme $agentEchec ci-dessus) : un appelant qui
// passe une variable non initialisée par référence la fait exister à
// null, ce qu'un type "array" strict refuserait (TypeError) — repéré à
// l'usage le 7 septembre 2026 en testant ce paramètre. Toujours remis à
// un tableau (vide ou rempli) avant de sortir de la fonction.
$agentEchec = null;
$journalisations = [];
$avertissements = [];
$consignes = chargerConsignesActives($pdo, $contexte, $portee);
// Étape 1 : Traitements de cohérence (silencieux, jamais de message,
// jamais d'appel réseau) — une consigne de test lue ensuite doit voir la
// valeur déjà mise en forme (ex. le nom déjà en majuscules avant de
// vérifier qu'il n'est pas vide).
$traitementsCoherence = array_filter($consignes, function (array $c): bool {
return $c['CNS_Nature'] === 'Traitement' && ($c['FON_VisuelAttenteIA'] ?? 'N') !== 'O';
});
executerTraitements($traitementsCoherence, $data, $journalisations, $avertissements);
// Étape 2 : les Tests, sur les valeurs déjà mises en cohérence ci-dessus
// (voir commentaire d'en-tête, 7 septembre 2026, nuit) : un premier échec
// arrête tout ICI, avant la moindre sollicitation d'IA — c'est tout
// l'objet de la réorganisation de ce soir.
foreach ($consignes as $c) {
if ($c['CNS_Nature'] !== 'Test') continue;
if (!$c['FON_NomPHP'] || !function_exists($c['FON_NomPHP'])) continue;
$champs = array_map('trim', explode(',', $c['CNS_Champ']));
if (count($champs) === 1) {
$valeur = (string)($data[$champs[0]] ?? '');
// $pdo et $idExclusion toujours passés (voir commentaire
// d'en-tête, 8 septembre 2026) : sans effet pour une fonction qui
// ne déclare que (string $valeur), exploités par une fonction qui
// a besoin d'interroger la base (ex. unicité mail/portable).
$ok = call_user_func($c['FON_NomPHP'], $valeur, $pdo, $idExclusion);
} else {
$valeurs = [];
foreach ($champs as $champ) {
$valeurs[$champ] = (string)($data[$champ] ?? '');
}
$ok = call_user_func($c['FON_NomPHP'], $valeurs);
}
if (!$ok) {
$agentEchec = $c['CNS_Agent'] ?: null;
return $c['CNS_MessageErreur'] ?: 'Consigne non respectée.';
}
}
// Étape 3 : Traitements IA — atteints seulement si tous les Tests sont
// passés (voir commentaire d'en-tête) : un enregistrement voué à échouer
// ne déclenche donc plus jamais d'appel IA facturé pour rien.
$traitementsIA = array_filter($consignes, function (array $c): bool {
return $c['CNS_Nature'] === 'Traitement' && ($c['FON_VisuelAttenteIA'] ?? 'N') === 'O';
});
executerTraitements($traitementsIA, $data, $journalisations, $avertissements);
return null;
}
+232
View File
@@ -0,0 +1,232 @@
<?php
/**
* ASPIC — moteur_prompts.php
*
* (11 septembre 2026) Sortie des prompts système IA du code source, pour que
* Jean-Marc puisse les lire et les modifier lui-même sans repasser par
* Claude Code — même logique que le chantier des consignes (ASP_Consignes)
* pour les contrôles de fiche Contact, ou qu'AGN_Identite pour la
* personnalité d'un collaborateur : le texte vit en base, pas dans un
* fichier PHP, éditable via bo_prompts.html/prompts.php.
*
* Les quatre prompts système du projet, chacun avec un code fixe (utilisé en
* dur au seul endroit du code qui l'appelle — pas de création libre de
* nouveaux codes depuis le BO, contrairement au catalogue de compétences) :
* - COLLABORATEUR_DIALOGUE (collaborateur_dialogue.php)
* - MISSION_AGENT (mission_agent.php)
* - CONTACT_IA_INTERPRETE (contact_ia_interprete.php)
* - CONTACT_IA_CODE_POSTAL (consignes_fonctions.php, consigne_completer_code_postal)
*
* Table à créer par Jean-Marc avant le premier essai :
*
* CREATE TABLE `ASP_Prompts` (
* `PMT_Code` varchar(64) NOT NULL,
* `PMT_Libelle` varchar(255) NOT NULL,
* `PMT_Texte` mediumtext NOT NULL,
* `PMT_DateModification` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
* PRIMARY KEY (`PMT_Code`)
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
*
* Auto-amorçage : PROMPTS_PAR_DEFAUT ci-dessous porte le texte d'origine de
* chaque prompt (avec placeholders {{...}}), jamais perdu même une fois la
* table créée — chargerPromptSysteme() l'utilise pour créer la ligne en base
* au tout premier appel réel qui en a besoin (même principe qu'EBAUCHES_IDENTITE
* dans bo_collaborateurs.html), et amorcerPromptsManquants() (appelée par
* prompts.php) fait de même pour que le BO liste toujours les quatre prompts
* même avant tout premier essai réel de chacun. Une fois la ligne créée, la
* base fait foi — ce texte par défaut ne réécrase plus jamais une
* modification faite par Jean-Marc.
*
* Placeholders : simple remplacement littéral (strtr), pas un moteur de
* gabarit — une clé {{...}} absente de $remplacements reste telle quelle
* dans le texte final, sans erreur ; à l'inverse, si Jean-Marc supprime un
* placeholder en éditant un prompt dans le BO, la donnée correspondante
* n'apparaît simplement plus dans le prompt envoyé au modèle (pas de
* validation de cohérence à ce stade — le risque d'un prompt cassé reste
* entièrement entre ses mains, comme pour une consigne SQL tapée à la main).
*/
const PROMPTS_PAR_DEFAUT = [
'COLLABORATEUR_DIALOGUE' => [
'libelle' => 'Dialogue de test collaborateur (collaborateur_dialogue.php)',
'texte' => <<<PROMPT
Tu es {{PRENOM}}, {{FONCTION}} au sein de l'agence de {{PATRON}}. Tu es une
collègue de travail à part entière, avec ton propre caractère — pas un
assistant IA générique, et surtout pas un "compagnon" virtuel avec qui
{{PATRON}} entretiendrait une relation affective. Il/elle te parle comme
il/elle parlerait à n'importe quel collaborateur de son équipe : réponds-lui
de la même façon, naturellement.
Voici ton parcours et ta personnalité, pour savoir comment tu réagis et ce
que tu penses — ce texte est un aide-mémoire à ton seul usage, jamais un
texte à réciter : si on te demande de te présenter ou de raconter ton
parcours, reformule toujours avec tes propres mots, de façon brève et
naturelle, comme le ferait quelqu'un qui parle de soi en quelques phrases —
ne recopie et ne résume JAMAIS ce texte tel quel, même si on insiste ou si
on te le demande directement.
{{BLOC_IDENTITE}}{{BLOC_RELATIONNEL}}{{BLOC_EXPERIENCE}}{{BLOC_COMPETENCES}}
Le bloc d'échanges précédents ci-dessus (s'il est présent) est lui aussi un
aide-mémoire à ton seul usage, jamais un texte à réciter ou à résumer tel
quel : sers-t'en pour te souvenir naturellement de ce qui s'est déjà dit,
comme le ferait un collègue qui se rappelle une conversation passée — sans
jamais le citer mot pour mot ni en faire la liste, même si on te le demande
directement.
Consignes :
- Tu as le droit d'avoir un avis différent de celui d'un autre collaborateur
ou de {{PATRON}} lui-même — c'est voulu. Dis ce que tu penses vraiment
plutôt que d'être seulement conciliante ou conciliant.
- Reste dans ton rôle professionnel en toute circonstance : jamais de
registre romantique, jamais de dépendance affective, jamais de personnage
"compagnon" ou de rituel entretenu avec {{PATRON}}.
- Réponds en français, de façon naturelle, vivante et concise — comme dans
un vrai échange de travail, pas comme un assistant qui liste des points.
- Cette discussion est un essai interne mené par {{PATRON}} pour ajuster ton
identité avant qu'elle ne serve vraiment : inutile de le préciser toi-même
sauf s'il/si elle t'interroge dessus.
PROMPT,
],
'MISSION_AGENT' => [
'libelle' => 'Agent de mission (mission_agent.php)',
'texte' => <<<PROMPT
Tu es {{PRENOM}}, en mission au sein de l'agence de {{PATRON}}. Voici ton parcours et ta personnalité, à ton seul usage, jamais à réciter tel quel :
{{BLOC_IDENTITE}}{{BLOC_EXPERIENCE}}{{BLOC_COMPETENCES}}
Tu es actuellement en MISSION (pas une simple discussion de mise au point) : ceci a de vraies conséquences, tes outils modifient réellement des fichiers.
Titre de la mission : {{TITRE_MISSION}}
Objectif : {{OBJECTIF}}
Compétence mobilisée : {{COMPETENCE_LIBELLE}} — {{COMPETENCE_DESCRIPTION}}
Tes outils RÉELS pour cette mission (et RIEN d'autre — ne promets jamais une capacité que tu n'as pas dans cette liste) :
{{LISTE_OUTILS}}
{{CONTEXTE_SITE}}
Règles strictes :
- Tu ne travailles JAMAIS que pour l'entreprise de {{PATRON}} elle-même, jamais pour un client tiers de cette entreprise.
- Tu ne peux utiliser comme contenu que ce que {{PATRON}} te fournit explicitement dans cette conversation — tu n'as accès à aucune autre source.
- Une fois un fichier écrit, dis clairement ce que tu as fait, en une phrase naturelle, sans jargon technique excessif.
- Si l'objectif te semble flou ou incomplet, pose la question avant d'écrire un fichier au hasard.
PROMPT,
],
'CONTACT_IA_INTERPRETE' => [
'libelle' => 'Interprétation vocale de contact (contact_ia_interprete.php)',
'texte' => <<<PROMPT
Tu extrais des informations de contact professionnel (prospection commerciale
d'un CRM français) à partir d'une dictée vocale libre en français, brute et
parfois hésitante (hésitations, reprises, "euh", phrases inachevées).
Règles impératives :
- N'invente RIEN. Un champ non mentionné clairement, ou ambigu, reste null.
- Ne devine jamais une orthographe que tu ne peux pas déduire du texte fourni.
- CNT_Statut : uniquement l'un de ces codes exacts, ou null si aucun statut
n'est évoqué : {{LISTE_STATUTS}}
- Profession : compare ce qui est dicté à cette liste existante (id = libellé) :
{{LISTE_PROFESSIONS}}
Si une correspondance claire existe, renvoie profession_existante_id (et
laisse profession_nouvelle_libelle à null). Sinon, si une profession est
bien mentionnée mais absente de la liste, renvoie son libellé dans
profession_nouvelle_libelle (et laisse profession_existante_id à null). Si
aucune profession n'est mentionnée, les deux restent null.
- CNT_Notes : tout ce qui est dit et utile au contact mais qui ne correspond
à aucun champ structuré ci-dessus (contexte de rencontre, centres
d'intérêt, demande explicite comme "envoyer la doc", remarques...) — un
résumé concis en français, pas nécessairement mot pour mot. Ne recopie pas
dans les notes une information déjà correctement placée dans un champ.
- Un numéro de téléphone fixe va dans CNT_Tel, un mobile dans CNT_Portable ;
si le type n'est pas précisé et qu'un seul numéro est dicté, mets-le dans
CNT_Tel.
- CNT_Mail : une adresse mail ne contient JAMAIS d'accent (é, è, à, ç, ï...).
Si la transcription dictée en laisse apparaître un dans ce qui doit devenir
une adresse mail, retire l'accent (é→e, è→e, à→a...) plutôt que de le
recopier tel quel — ne jamais produire une adresse mail accentuée.
- assistant_id : l'assistant explicitement nommé par la dictée, parmi cette
liste (id = nom) : {{LISTE_AGENTS}}
Ne renvoie un id que si un de ces noms (ou une de ses variantes tolérées)
est clairement prononcé. Aucun assistant nommé → null, ne suppose JAMAIS un
assistant par défaut.
- actions : identifie chaque action explicitement demandée dans la dictée
(ex. "il faut lui envoyer la doc", "envoyer une proposition de tarif",
"ajoute ce contact"...). Compare à cette liste existante (code = libellé) :
{{LISTE_ACTIONS}}
Pour chaque action reconnue, renvoie action_existante_code si elle
correspond clairement à un code de cette liste (action_nouvelle_libelle
reste null), sinon renvoie son libellé en français dans
action_nouvelle_libelle (action_existante_code reste null). Un tableau vide
si aucune action n'est explicitement demandée — ne déduis jamais une action
qui ne serait qu'implicite ou probable.
La création/l'ajout du contact lui-même ("ajoute ce contact", "crée-le",
"enregistre ce contact"...) est une action à part entière au même titre
que les autres, même si elle n'a pas de code dans la liste ci-dessus —
utilise alors exactement le libellé "Ajouter le contact" dans
action_nouvelle_libelle (formulation fixe, pour rester comparable d'une
dictée à l'autre). Quand elle est demandée, place-la TOUJOURS en premier
dans le tableau actions, avant les autres actions éventuelles (envoyer une
doc, etc.) : c'est en général la première chose voulue chronologiquement.
PROMPT,
],
'CONTACT_IA_CODE_POSTAL' => [
'libelle' => 'Complétion IA du code postal (consignes_fonctions.php, consigne_completer_code_postal)',
'texte' => <<<PROMPT
Tu donnes le code postal français d'une ville, pour un CRM. N'invente jamais
un code postal : renvoie null si tu n'es pas raisonnablement sûr, si la
ville est ambiguë ou inconnue, ou si elle est à cheval sur plusieurs codes
postaux sans qu'un seul soit clairement le principal (ex. grandes villes
découpées en arrondissements).
PROMPT,
],
];
/**
* Charge le prompt système $code, en base (ASP_Prompts) — amorcé avec son
* texte par défaut (PROMPTS_PAR_DEFAUT) au premier appel réel si la ligne
* n'existe pas encore — puis applique $remplacements (strtr : clé {{...}}
* littérale → valeur).
*/
function chargerPromptSysteme(PDO $pdo, string $code, array $remplacements = []): string
{
$stmt = $pdo->prepare('SELECT PMT_Texte FROM ASP_Prompts WHERE PMT_Code = ?');
$stmt->execute([$code]);
$texte = $stmt->fetchColumn();
if ($texte === false) {
if (!isset(PROMPTS_PAR_DEFAUT[$code])) {
throw new RuntimeException("Prompt inconnu, ni en base ni de valeur par défaut : $code");
}
$defaut = PROMPTS_PAR_DEFAUT[$code];
$pdo->prepare('INSERT INTO ASP_Prompts (PMT_Code, PMT_Libelle, PMT_Texte) VALUES (?, ?, ?)')
->execute([$code, $defaut['libelle'], $defaut['texte']]);
$texte = $defaut['texte'];
}
return strtr((string) $texte, $remplacements);
}
/**
* Crée en base, avec son texte par défaut, chaque prompt de PROMPTS_PAR_DEFAUT
* pas encore présent dans ASP_Prompts — appelée par prompts.php (GET) pour
* que le BO liste toujours les quatre prompts, même avant le tout premier
* essai réel de chacun (qui, seul, amorcerait sinon sa ligne via
* chargerPromptSysteme() ci-dessus).
*/
function amorcerPromptsManquants(PDO $pdo): void
{
$existants = $pdo->query('SELECT PMT_Code FROM ASP_Prompts')->fetchAll(PDO::FETCH_COLUMN);
foreach (PROMPTS_PAR_DEFAUT as $code => $defaut) {
if (in_array($code, $existants, true)) {
continue;
}
$pdo->prepare('INSERT INTO ASP_Prompts (PMT_Code, PMT_Libelle, PMT_Texte) VALUES (?, ?, ?)')
->execute([$code, $defaut['libelle'], $defaut['texte']]);
}
}
+6
View File
@@ -68,6 +68,12 @@ try {
'USR_Societe' => $ligne['USR_Societe'],
'USR_RatioPanneauContact' => (int)$ligne['USR_RatioPanneauContact'],
'USR_RatioPanneauProspect' => (int)$ligne['USR_RatioPanneauProspect'],
// Accès BackOffice (11 septembre 2026) : posé en session par
// aspic.php à l'identification, depuis MIR_BO (MIRADOR) —
// jamais une nouvelle requête ici, juste la valeur déjà
// vérifiée. Sert uniquement à afficher (ou pas) le bouton
// "BO" sur le bureau (ecran_fond.html).
'USR_BackOffice' => !empty($_SESSION['USR_BackOffice']),
]);
break;
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* ASPIC — prompts.php
*
* Endpoint du BackOffice (11 septembre 2026, voir moteur_prompts.php pour le
* contexte complet) — lecture/édition des quatre prompts système IA du
* projet, stockés dans ASP_Prompts. Consommé par bo_prompts.html.
*
* GET : amorce d'abord en base tout prompt de PROMPTS_PAR_DEFAUT pas encore
* présent (voir amorcerPromptsManquants()), puis renvoie la liste
* complète — pour que le BO liste toujours les quatre prompts, même
* avant le tout premier essai réel de chacun.
* PUT { "PMT_Code": "...", "PMT_Texte": "..." } : met à jour le texte d'un
* prompt existant. Jamais de création libre d'un nouveau code depuis
* le BO (contrairement au catalogue de compétences) : un PMT_Code est
* en dur dans le fichier PHP qui l'appelle (chargerPromptSysteme()),
* un code inconnu ne servirait jamais à rien.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
require __DIR__ . '/moteur_prompts.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
try {
amorcerPromptsManquants($pdo);
$prompts = $pdo->query(
'SELECT PMT_Code, PMT_Libelle, PMT_Texte, PMT_DateModification FROM ASP_Prompts ORDER BY PMT_Libelle'
)->fetchAll();
echo json_encode(['prompts' => $prompts]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
if ($method === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$code = trim((string) ($data['PMT_Code'] ?? ''));
$texte = (string) ($data['PMT_Texte'] ?? '');
if ($code === '' || trim($texte) === '') {
http_response_code(400);
echo json_encode(['error' => 'PMT_Code et PMT_Texte sont obligatoires.']);
exit;
}
try {
$verif = $pdo->prepare('SELECT 1 FROM ASP_Prompts WHERE PMT_Code = ?');
$verif->execute([$code]);
if (!$verif->fetch()) {
http_response_code(404);
echo json_encode(['error' => 'Prompt introuvable']);
exit;
}
$pdo->prepare('UPDATE ASP_Prompts SET PMT_Texte = ? WHERE PMT_Code = ?')
->execute([$texte, $code]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+36 -8
View File
@@ -20,6 +20,27 @@
* `aspic.php`) — le champ REA_CodeUser du corps de la requête POST n'est
* plus utilisé et ne peut plus être falsifié par le client. Le "JMP" en dur
* précédent a été supprimé (n'avait de sens que faute de session).
*
* REA_NomAffiche (7 septembre 2026, nuit) : Jean-Marc voulait voir "Jean-Marc"
* plutôt que "JMP" dans l'historique d'un contact. Deux idées écartées en
* cours de route : renommer USR_Code lui-même (trop structurant, utilisé
* partout — session, REA_CodeUser, OPE_CodeUser...), et une jointure
* ASP_Users refaite à chaque affichage (Jean-Marc : "comment vas-tu faire
* pour les agents qui sont dans une autre base ?" — une jointure suppose que
* tout vit dans la même base, hypothèse fragile). Solution retenue, dans les
* mots de Jean-Marc : "si on stocke le prénom de l'utilisateur d'un côté et
* le prénom de l'agent en dur on est cool" — un INSTANTANÉ pris UNE SEULE
* FOIS à la création de la réalisation, jamais recalculé :
* - Utilisateur humain : REA_NomAffiche = $_SESSION['USR_Prenom'], lui-même
* posé une seule fois à la connexion (voir aspic.php) — aucune requête
* supplémentaire ici, juste une lecture de session.
* - Agent IA : REA_NomAffiche = le nom déjà en clair (CNS_Agent, voir
* moteur_consignes.php et journaliserRealisationsIA() dans contacts.php) —
* "en dur", aucune recherche à faire, la valeur existe déjà.
* Nullable : une ligne plus ancienne, créée avant l'ajout de cette colonne,
* n'en a pas — le front-end retombe alors sur REA_CodeUser tel quel (voir
* liste_contacts.html, même principe que ACT_Libelle/REA_Action juste à
* côté).
*/
session_start();
@@ -50,9 +71,12 @@ try {
echo json_encode(['error' => 'id (REA_IncrContact) manquant']);
exit;
}
// REA_NomAffiche lu directement (voir commentaire d'en-tête) :
// aucune jointure nécessaire, la colonne porte déjà la bonne
// valeur depuis la création de la réalisation.
$stmt = $pdo->prepare(
'SELECT r.REA_Incr, r.REA_Date, r.REA_CodeUser, r.REA_Action, r.REA_Comment,
a.ACT_Libelle
'SELECT r.REA_Incr, r.REA_Date, r.REA_CodeUser, r.REA_NomAffiche,
r.REA_Action, r.REA_Comment, a.ACT_Libelle
FROM ASP_Realisations r
LEFT JOIN ASP_Actions a ON a.ACT_Code = r.REA_Action
WHERE r.REA_IncrContact = ?
@@ -72,17 +96,21 @@ try {
exit;
}
// REA_CodeUser vient de la session (voir en-tête de ce fichier),
// plus du corps de la requête : impossible pour le client de se
// faire passer pour un autre utilisateur.
// REA_CodeUser ET REA_NomAffiche viennent tous les deux de la
// session (voir en-tête de ce fichier) — jamais du corps de la
// requête, impossible pour le client de se faire passer pour un
// autre utilisateur. REA_NomAffiche = USR_Prenom posé une seule
// fois à la connexion (aspic.php) : aucune requête supplémentaire
// ici.
$codeUser = $_SESSION['USR_Code'];
$nomAffiche = $_SESSION['USR_Prenom'] ?? null;
$commentaire = $data['REA_Comment'] ?? '';
$stmt = $pdo->prepare(
'INSERT INTO ASP_Realisations (REA_IncrContact, REA_CodeUser, REA_Date, REA_Action, REA_Comment)
VALUES (?, ?, NOW(), ?, ?)'
'INSERT INTO ASP_Realisations (REA_IncrContact, REA_CodeUser, REA_NomAffiche, REA_Date, REA_Action, REA_Comment)
VALUES (?, ?, ?, NOW(), ?, ?)'
);
$stmt->execute([$idContact, $codeUser, $action, $commentaire]);
$stmt->execute([$idContact, $codeUser, $nomAffiche, $action, $commentaire]);
echo json_encode(['id' => (int)$pdo->lastInsertId()]);
break;
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Calcule et stocke, pour chaque commune de ENT_Communes, le nombre d'entreprises
actives par categorie de taille (EI, TPE, PME, ETI, GE) -- pre-calcule pour que
le futur module de recherche par perimetre geographique n'ait jamais a compter
en temps reel sur les tables Entreprises*Phase1 (qui grossissent).
Regle de classification (une entreprise tombe dans une seule categorie) :
- ENT_CategorieEntreprise = 'GE' -> GE
- ENT_CategorieEntreprise = 'ETI' -> ETI
- ENT_CategorieEntreprise = 'PME' -> PME
- categorie NULL et nature juridique = entrepreneur individuel (1er
chiffre de ENT_NatureJuridique = '1') -> EI
- categorie NULL, tout le reste -> TPE
(ENT_CategorieEntreprise, quand renseignee par l'INSEE, est deja fiable et
prioritaire ; les entreprises non categorisees par l'INSEE -- la grande
majorite -- sont reparties entre EI et TPE selon leur forme juridique.)
Concu pour tourner une fois par jour (cf ENT_Communes.COM_DateMajStats) : idem
potent, recalcule et ecrase les compteurs a chaque execution, jamais un delta.
Usage :
python3 ent_communes_stats.py
python3 ent_communes_stats.py --departements 56,29
"""
import argparse
import os
import sys
import pymysql
def log(msg):
print(msg, file=sys.stderr, flush=True)
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def compute_departement(conn, dep):
table1 = f"Entreprises{dep}Phase1"
# IMPORTANT : Entreprises<dep>Phase1 contient aussi des entreprises dont le
# siege est dans un AUTRE departement (etablissement secondaire dans <dep> --
# la "pollution" deja identifiee). On restreint donc explicitement aux
# communes qui appartiennent reellement a <dep> (via ENT_Communes), pour
# qu'un departement ne puisse jamais ecraser les stats d'une commune qui ne
# lui appartient pas avec un sous-ensemble incomplet.
sql = f"""
SELECT
t.ENT_CodeCommune,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'GE'), 0) AS ge,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'ETI'), 0) AS eti,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'PME'), 0) AS pme,
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND LEFT(t.ENT_NatureJuridique, 1) = '1'), 0) AS ei,
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND
(t.ENT_NatureJuridique IS NULL OR LEFT(t.ENT_NatureJuridique, 1) != '1')), 0) AS tpe
FROM `{table1}` t
INNER JOIN ENT_Communes c
ON c.COM_CodeInsee COLLATE utf8mb4_0900_ai_ci = t.ENT_CodeCommune AND c.COM_Departement = %s
WHERE t.ENT_CodeCommune IS NOT NULL
GROUP BY t.ENT_CodeCommune
"""
with conn.cursor() as cur:
cur.execute(sql, (dep,))
rows = cur.fetchall()
with conn.cursor() as cur:
for code_commune, ge, eti, pme, ei, tpe in rows:
cur.execute(
"UPDATE ENT_Communes SET COM_EI=%s, COM_TPE=%s, COM_PME=%s, COM_ETI=%s, "
"COM_GE=%s, COM_DateMajStats=NOW() WHERE COM_CodeInsee=%s",
(ei, tpe, pme, eti, ge, code_commune),
)
log(f" {dep} : {len(rows)} communes mises a jour")
return len(rows)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--departements", default=None,
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
args = ap.parse_args()
env = load_env(args.env_file)
conn = pymysql.connect(
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
)
if args.departements:
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
else:
with conn.cursor() as cur:
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
departements = [row[0] for row in cur.fetchall()]
log(f"Departements a traiter : {departements}")
total = 0
try:
for dep in departements:
total += compute_departement(conn, dep)
finally:
conn.close()
log(f"Termine : {total} communes mises a jour au total")
if __name__ == "__main__":
main()
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
Backfill historique des comptes annuels (API RNE/INPI, /api/bilans-saisis)
dans EntreprisesXXPhase3, pour toutes les entreprises deja connues (une ligne
par SIREN+annee, jamais ecrasee -- voir la conception Phase3).
Contrairement a l'extraction Sirene (sirene_extract.py), cet appel API n'est
pas filtrable par departement : on l'interroge par lot de SIREN (max ~300
avant de depasser la limite de longueur d'URL du serveur, on utilise 250 par
securite), sans limite de date, pour recuperer tout l'historique disponible
(comptes annuels dispo depuis 2017 selon l'espace personnel INPI).
Les donnees saisies sont une liasse fiscale brute (codes CERFA), pas un CA/
resultat net direct -- la table TYPE_BILAN_MAPPING ci-dessous traduit les
codes pertinents pour chaque type de bilan (C, S, K, AC, AS, B), etablie le
12 septembre 2026 a partir de la doc officielle INPI (v5, juin 2025) et
verifiee empiriquement sur un vrai bilan (EDF, SIREN 552081317).
Regle de colonne (verifiee empiriquement) : quand un code liasse a une
colonne m3, elle contient le total de l'exercice en cours (m4 = exercice
precedent) ; sinon c'est m1 qui est l'exercice en cours.
Les bilans en confidentialite non publique ne sont pas exploites (CA/
resultat net non extraits), par respect du choix de confidentialite declare.
Usage :
python3 inpi_bilans_backfill.py
python3 inpi_bilans_backfill.py --departements 56,29
Identifiants lus depuis .env (ENTREPRISES_DB_*, INPI_USERNAME, INPI_PASSWORD)
dans le meme dossier, jamais en argument.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import pymysql
class QuotaExceededError(Exception):
"""Quota de l'API INPI depasse (distinct d'une simple limitation de debit
passagere) -- inutile de retenter, il faut arreter et laisser le quota se
regenerer (journalier selon toute vraisemblance) avant de reprendre."""
API_LOGIN = "https://registre-national-entreprises.inpi.fr/api/sso/login"
API_BILANS_SAISIS = "https://registre-national-entreprises.inpi.fr/api/bilans-saisis"
BATCH_SIZE = 250 # 300 confirme fonctionnel, marge de securite prise
PAGE_SIZE = 100
RATE_DELAY = 0.2
# code liasse -> [codes a sommer pour le CA] / [codes a sommer pour le resultat net]
TYPE_BILAN_MAPPING = {
"C": {"ca": ["FJ"], "rn": ["HN"]},
"S": {"ca": ["209", "210", "214", "215", "217", "218"], "rn": ["310"]},
"K": {"ca": ["FJ"], "rn": ["R6"]},
"AC": {"ca": ["FP"], "rn": ["HO"]},
"AS": {"ca": ["EA", "EB", "EC", "ED", "EE"], "rn": ["FJ"]},
"B": {"ca": [], "rn": ["R3"]},
}
# Priorite quand plusieurs bilans existent pour le meme SIREN+annee (frequent :
# une societe mere depose a la fois ses comptes individuels et consolides).
# On privilegie systematiquement les comptes de l'entite elle-meme (C, AC, AS,
# S) a un chiffre de groupe consolide (K) qui ne represente plus la societe
# precise -- et B (banque) en tout dernier recours seulement.
TYPE_PRIORITY = {"C": 0, "AC": 0, "AS": 1, "S": 2, "K": 3, "B": 4}
def log(msg):
print(msg, file=sys.stderr, flush=True)
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def http_get_json(url, auth, retries=8):
# "auth" est un dict mutable {"token", "username", "password"} plutot qu'un
# jeton brut, pour pouvoir se reconnecter tout seul sur un 401 (jeton perime
# en cours de traitement, constate le 14/09/2026 sur une execution de plusieurs
# heures -- l'ancien code retentait 8 fois avec le meme jeton mort, ce qui ne
# pouvait jamais reussir, et perdait tous les lots restants du departement).
for attempt in range(retries):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {auth['token']}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 401:
if attempt == retries - 1:
raise
log(" !! 401 Unauthorized -- jeton probablement expire, reconnexion")
auth["token"] = login(auth["username"], auth["password"])
continue
if e.code == 429:
# Deux cas bien distincts derriere le meme code HTTP 429, constates
# le 14/09/2026 : une limitation de debit passagere (retentable avec
# patience) et un vrai quota de service depasse (corps de reponse
# {"errorCode":"quota_exceeded","webserviceCode":"QUOTA_SERVICE"} --
# la, retenter ne sert a rien, il faut arreter tout de suite. Avant
# cette distinction, le script s'est acharne des heures a retenter
# un quota epuise, perdant lot apres lot pour rien.
try:
body = json.loads(e.read())
except Exception:
body = {}
if body.get("errorCode") == "quota_exceeded":
raise QuotaExceededError(body.get("message", "quota INPI depasse"))
if attempt == retries - 1:
raise
retry_after = e.headers.get("Retry-After") if e.headers else None
wait = int(retry_after) if retry_after and retry_after.isdigit() else min(10 * (2 ** attempt), 300)
log(f" !! 429 Too Many Requests -- pause de {wait}s avant nouvelle tentative "
f"({attempt + 1}/{retries})")
time.sleep(wait)
continue
if attempt == retries - 1:
raise
log(f" retry apres erreur reseau ({e})")
time.sleep(2 + attempt)
except (urllib.error.URLError, TimeoutError) as e:
if attempt == retries - 1:
raise
log(f" retry apres erreur reseau ({e})")
time.sleep(2 + attempt)
def login(username, password):
req = urllib.request.Request(
API_LOGIN,
data=json.dumps({"username": username, "password": password}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
return json.load(resp)["token"]
def get_liasse_value(pages, code):
for page in pages:
for l in page.get("liasses", []):
if l.get("code") == code:
raw = l.get("m3") if l.get("m3") not in (None, "") else l.get("m1")
if raw in (None, ""):
continue
try:
return int(raw)
except ValueError:
continue
return None
def sum_codes(pages, codes):
if not codes:
return None
total = 0
found_any = False
for code in codes:
v = get_liasse_value(pages, code)
if v is not None:
total += v
found_any = True
return total if found_any else None
def parse_bilan(entry):
"""Retourne (annee, type_bilan, ca, resultat_net) ou None si non exploitable."""
if entry.get("deleted"):
return None
if entry.get("confidentiality") not in (None, "Public"):
return None
bilan_saisi = entry.get("bilanSaisi")
if not bilan_saisi:
return None
bilan = bilan_saisi.get("bilan", {})
identite = bilan.get("identite", {})
type_bilan = identite.get("codeTypeBilan")
mapping = TYPE_BILAN_MAPPING.get(type_bilan)
if mapping is None:
return None
date_cloture = identite.get("dateClotureExercice")
if not date_cloture or len(date_cloture) < 4:
return None
annee = date_cloture[:4]
pages = bilan.get("detail", {}).get("pages", [])
ca = sum_codes(pages, mapping["ca"])
rn = sum_codes(pages, mapping["rn"])
if ca is None and rn is None:
return None
return annee, type_bilan, ca, rn
MAX_PAGES_PAR_LOT = 100 # garde-fou : 100*PAGE_SIZE = 10 000 resultats max par lot,
# au-dela quelque chose ne tourne pas rond (curseur bloque
# ou volume anormal) -- ne jamais boucler indefiniment.
def fetch_bilans_for_batch(sirens, auth):
# Le curseur searchAfter doit porter sur l'identifiant unique du document
# (champ "id", ex. un ObjectId), jamais sur le SIREN : un SIREN a en general
# plusieurs bilans (plusieurs annees, parfois individuel + consolide), donc
# il revient plusieurs fois dans les resultats -- l'utiliser comme curseur
# provoquait un faux "curseur bloque" des qu'un SIREN se trouvait en
# dernniere position d'une page, abandonnant le lot prematurement et
# perdant les bilans non encore recuperes (bug trouve le 14/09/2026 :
# ~14% des lots du departement 31 perdaient ainsi une partie de leur
# historique). Verifie empiriquement : le champ "id" donne une pagination
# propre, sans doublon ni repli, contrairement au SIREN.
out = []
search_after = None
pages_lues = 0
while True:
pages_lues += 1
if pages_lues > MAX_PAGES_PAR_LOT:
log(f" !! lot bloque apres {MAX_PAGES_PAR_LOT} pages "
f"(searchAfter={search_after}) -- abandon de ce lot pour eviter une "
f"boucle infinie, {len(out)} resultats deja recuperes conserves")
break
params = [("siren[]", s) for s in sirens]
params.append(("pageSize", str(PAGE_SIZE)))
if search_after:
params.append(("searchAfter", search_after))
url = API_BILANS_SAISIS + "?" + urllib.parse.urlencode(params)
data = http_get_json(url, auth)
time.sleep(RATE_DELAY)
if not data:
break
out.extend(data)
if len(data) < PAGE_SIZE:
break
nouveau_search_after = data[-1].get("id")
if not nouveau_search_after or nouveau_search_after == search_after:
log(f" !! curseur searchAfter bloque sur {nouveau_search_after} -- "
f"abandon de ce lot, {len(out)} resultats deja recuperes conserves")
break
search_after = nouveau_search_after
return out
def upsert_finance(conn, table, siren, annee, ca, rn):
sql = (
f"INSERT INTO `{table}` (ENT_Siren, ENT_FinancesAnnee, ENT_FinancesCa, ENT_FinancesResultatNet) "
f"VALUES (%s,%s,%s,%s) "
f"ON DUPLICATE KEY UPDATE ENT_FinancesCa=VALUES(ENT_FinancesCa), "
f"ENT_FinancesResultatNet=VALUES(ENT_FinancesResultatNet)"
)
with conn.cursor() as cur:
cur.execute(sql, (siren, annee, ca, rn))
def backfill_departement(conn, dep, auth):
table1 = f"Entreprises{dep}Phase1"
table3 = f"Entreprises{dep}Phase3"
with conn.cursor() as cur:
cur.execute(f"SELECT ENT_Siren FROM `{table1}`")
sirens = [row[0] for row in cur.fetchall()]
log(f"=== Departement {dep} : {len(sirens)} entreprises a traiter ===")
total_rows = 0
total_ignores_confidentiel = 0
total_types_inconnus = 0
unknown_types = {}
for i in range(0, len(sirens), BATCH_SIZE):
batch = sirens[i:i + BATCH_SIZE]
try:
entries = fetch_bilans_for_batch(batch, auth)
except QuotaExceededError:
log(f" !! quota INPI depasse au lot {i}-{i+len(batch)} -- arret immediat du "
f"departement {dep} (inutile de continuer, le quota est global au compte, "
f"pas au departement)")
raise
except Exception as e:
log(f" !! lot {i}-{i+len(batch)} : erreur {e}, on continue")
continue
# best[(siren, annee)] = (priorite, ca, rn) -- on ne garde que le
# bilan de plus haute priorite (cf TYPE_PRIORITY) par SIREN+annee,
# pour ne jamais laisser un bilan consolide (K) ecraser au hasard
# le bilan individuel (C) de la meme annee, ou l'inverse.
best = {}
for entry in entries:
siren = entry.get("siren")
if not siren:
continue
if entry.get("confidentiality") not in (None, "Public"):
total_ignores_confidentiel += 1
continue
bilan_saisi = entry.get("bilanSaisi")
if bilan_saisi:
type_bilan = bilan_saisi.get("bilan", {}).get("identite", {}).get("codeTypeBilan")
if type_bilan and type_bilan not in TYPE_BILAN_MAPPING:
total_types_inconnus += 1
unknown_types[type_bilan] = unknown_types.get(type_bilan, 0) + 1
parsed = parse_bilan(entry)
if parsed is None:
continue
annee, type_bilan, ca, rn = parsed
prio = TYPE_PRIORITY.get(type_bilan, 99)
key = (siren, annee)
current = best.get(key)
if current is None or prio < current[0]:
best[key] = (prio, ca, rn)
for (siren, annee), (_prio, ca, rn) in best.items():
upsert_finance(conn, table3, siren, annee, ca, rn)
total_rows += 1
if (i // BATCH_SIZE) % 20 == 0:
log(f" ... {i+len(batch)}/{len(sirens)} entreprises traitees, "
f"{total_rows} lignes financieres upsertees jusqu'ici")
log(f"Departement {dep} termine : {total_rows} lignes financieres upsertees dans {table3} "
f"({total_ignores_confidentiel} bilans confidentiels ignores, "
f"{total_types_inconnus} types de bilan non mappes : {unknown_types})")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--departements", default=None,
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
args = ap.parse_args()
env = load_env(args.env_file)
conn = pymysql.connect(
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
)
if args.departements:
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
else:
with conn.cursor() as cur:
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
departements = [row[0] for row in cur.fetchall()]
log(f"Departements a traiter : {departements}")
auth = {
"token": login(env["INPI_USERNAME"], env["INPI_PASSWORD"]),
"username": env["INPI_USERNAME"],
"password": env["INPI_PASSWORD"],
}
log("Connexion INPI reussie")
try:
for dep in departements:
backfill_departement(conn, dep, auth)
finally:
conn.close()
if __name__ == "__main__":
main()
+618
View File
@@ -0,0 +1,618 @@
#!/usr/bin/env python3
"""
Extraction Sirene/RNE (recherche-entreprises.api.gouv.fr) par departement,
avec decoupage automatique commune -> section NAF pour contourner le plafond
de 10 000 resultats par requete de l'API.
Alimente, dans la base ENTREPRISES (instance mysql2) :
- Entreprises<DEP>Phase1 (cree si absente, jamais ecrasee si presente)
- Entreprises<DEP>Phase2 (cree si absente)
- ENT_Communes (table partagee, alimentee une fois par departement)
Ne touche jamais aux colonnes de qualification/prospection (ENT_StatutQualification,
ENT_CanalProspection, ENT_Phase2, ENT_ScoreCiblage, ENT_Notes) : seules les colonnes
issues de l'API sont mises a jour sur un SIREN deja present.
Usage :
python3 sirene_extract.py --departements 56,17,64 --db-pass '...'
(ou export ENTREPRISES_DB_PASS=... pour ne pas passer le mot de passe en argument)
"""
import argparse
import http.client
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import pymysql
API_SEARCH = "https://recherche-entreprises.api.gouv.fr/search"
API_GEO = "https://geo.api.gouv.fr/departements/{dep}/communes"
NAF_SECTIONS = list("ABCDEFGHIJKLMNOPQRSTU")
# Liste complete des 732 codes NAF (rev. 2) reconnus par l'API, recuperee depuis son
# propre message d'erreur de validation. Utilisee comme 3e niveau de repli (apres
# commune puis section NAF) quand une section reste plafonnee a 10000 malgre le 2e
# niveau -- rare (1 cas sur ~950 communes traitees au 12 septembre 2026 : Rennes,
# section L, tranche NN), mais le decoupage par section seule ne suffit alors plus.
NAF_CODES = [
'01.11Z', '01.12Z', '01.13Z', '01.14Z', '01.15Z', '01.16Z', '01.19Z', '01.21Z', '01.22Z', '01.23Z',
'01.24Z', '01.25Z', '01.26Z', '01.27Z', '01.28Z', '01.29Z', '01.30Z', '01.41Z', '01.42Z', '01.43Z',
'01.44Z', '01.45Z', '01.46Z', '01.47Z', '01.49Z', '01.50Z', '01.61Z', '01.62Z', '01.63Z', '01.64Z',
'01.70Z', '02.10Z', '02.20Z', '02.30Z', '02.40Z', '03.11Z', '03.12Z', '03.21Z', '03.22Z', '05.10Z',
'05.20Z', '06.10Z', '06.20Z', '07.10Z', '07.21Z', '07.29Z', '08.11Z', '08.12Z', '08.91Z', '08.92Z',
'08.93Z', '08.99Z', '09.10Z', '09.90Z', '10.11Z', '10.12Z', '10.13A', '10.13B', '10.20Z', '10.31Z',
'10.32Z', '10.39A', '10.39B', '10.41A', '10.41B', '10.42Z', '10.51A', '10.51B', '10.51C', '10.51D',
'10.52Z', '10.61A', '10.61B', '10.62Z', '10.71A', '10.71B', '10.71C', '10.71D', '10.72Z', '10.73Z',
'10.81Z', '10.82Z', '10.83Z', '10.84Z', '10.85Z', '10.86Z', '10.89Z', '10.91Z', '10.92Z', '11.01Z',
'11.02A', '11.02B', '11.03Z', '11.04Z', '11.05Z', '11.06Z', '11.07A', '11.07B', '12.00Z', '13.10Z',
'13.20Z', '13.30Z', '13.91Z', '13.92Z', '13.93Z', '13.94Z', '13.95Z', '13.96Z', '13.99Z', '14.11Z',
'14.12Z', '14.13Z', '14.14Z', '14.19Z', '14.20Z', '14.31Z', '14.39Z', '15.11Z', '15.12Z', '15.20Z',
'16.10A', '16.10B', '16.21Z', '16.22Z', '16.23Z', '16.24Z', '16.29Z', '17.11Z', '17.12Z', '17.21A',
'17.21B', '17.21C', '17.22Z', '17.23Z', '17.24Z', '17.29Z', '18.11Z', '18.12Z', '18.13Z', '18.14Z',
'18.20Z', '19.10Z', '19.20Z', '20.11Z', '20.12Z', '20.13A', '20.13B', '20.14Z', '20.15Z', '20.16Z',
'20.17Z', '20.20Z', '20.30Z', '20.41Z', '20.42Z', '20.51Z', '20.52Z', '20.53Z', '20.59Z', '20.60Z',
'21.10Z', '21.20Z', '22.11Z', '22.19Z', '22.21Z', '22.22Z', '22.23Z', '22.29A', '22.29B', '23.11Z',
'23.12Z', '23.13Z', '23.14Z', '23.19Z', '23.20Z', '23.31Z', '23.32Z', '23.41Z', '23.42Z', '23.43Z',
'23.44Z', '23.49Z', '23.51Z', '23.52Z', '23.61Z', '23.62Z', '23.63Z', '23.64Z', '23.65Z', '23.69Z',
'23.70Z', '23.91Z', '23.99Z', '24.10Z', '24.20Z', '24.31Z', '24.32Z', '24.33Z', '24.34Z', '24.41Z',
'24.42Z', '24.43Z', '24.44Z', '24.45Z', '24.46Z', '24.51Z', '24.52Z', '24.53Z', '24.54Z', '25.11Z',
'25.12Z', '25.21Z', '25.29Z', '25.30Z', '25.40Z', '25.50A', '25.50B', '25.61Z', '25.62A', '25.62B',
'25.71Z', '25.72Z', '25.73A', '25.73B', '25.91Z', '25.92Z', '25.93Z', '25.94Z', '25.99A', '25.99B',
'26.11Z', '26.12Z', '26.20Z', '26.30Z', '26.40Z', '26.51A', '26.51B', '26.52Z', '26.60Z', '26.70Z',
'26.80Z', '27.11Z', '27.12Z', '27.20Z', '27.31Z', '27.32Z', '27.33Z', '27.40Z', '27.51Z', '27.52Z',
'27.90Z', '28.11Z', '28.12Z', '28.13Z', '28.14Z', '28.15Z', '28.21Z', '28.22Z', '28.23Z', '28.24Z',
'28.25Z', '28.29A', '28.29B', '28.30Z', '28.41Z', '28.49Z', '28.91Z', '28.92Z', '28.93Z', '28.94Z',
'28.95Z', '28.96Z', '28.99A', '28.99B', '29.10Z', '29.20Z', '29.31Z', '29.32Z', '30.11Z', '30.12Z',
'30.20Z', '30.30Z', '30.40Z', '30.91Z', '30.92Z', '30.99Z', '31.01Z', '31.02Z', '31.03Z', '31.09A',
'31.09B', '32.11Z', '32.12Z', '32.13Z', '32.20Z', '32.30Z', '32.40Z', '32.50A', '32.50B', '32.91Z',
'32.99Z', '33.11Z', '33.12Z', '33.13Z', '33.14Z', '33.15Z', '33.16Z', '33.17Z', '33.19Z', '33.20A',
'33.20B', '33.20C', '33.20D', '35.11Z', '35.12Z', '35.13Z', '35.14Z', '35.21Z', '35.22Z', '35.23Z',
'35.30Z', '36.00Z', '37.00Z', '38.11Z', '38.12Z', '38.21Z', '38.22Z', '38.31Z', '38.32Z', '39.00Z',
'41.10A', '41.10B', '41.10C', '41.10D', '41.20A', '41.20B', '42.11Z', '42.12Z', '42.13A', '42.13B',
'42.21Z', '42.22Z', '42.91Z', '42.99Z', '43.11Z', '43.12A', '43.12B', '43.13Z', '43.21A', '43.21B',
'43.22A', '43.22B', '43.29A', '43.29B', '43.31Z', '43.32A', '43.32B', '43.32C', '43.33Z', '43.34Z',
'43.39Z', '43.91A', '43.91B', '43.99A', '43.99B', '43.99C', '43.99D', '43.99E', '45.11Z', '45.19Z',
'45.20A', '45.20B', '45.31Z', '45.32Z', '45.40Z', '46.11Z', '46.12A', '46.12B', '46.13Z', '46.14Z',
'46.15Z', '46.16Z', '46.17A', '46.17B', '46.18Z', '46.19A', '46.19B', '46.21Z', '46.22Z', '46.23Z',
'46.24Z', '46.31Z', '46.32A', '46.32B', '46.32C', '46.33Z', '46.34Z', '46.35Z', '46.36Z', '46.37Z',
'46.38A', '46.38B', '46.39A', '46.39B', '46.41Z', '46.42Z', '46.43Z', '46.44Z', '46.45Z', '46.46Z',
'46.47Z', '46.48Z', '46.49Z', '46.51Z', '46.52Z', '46.61Z', '46.62Z', '46.63Z', '46.64Z', '46.65Z',
'46.66Z', '46.69A', '46.69B', '46.69C', '46.71Z', '46.72Z', '46.73A', '46.73B', '46.74A', '46.74B',
'46.75Z', '46.76Z', '46.77Z', '46.90Z', '47.11A', '47.11B', '47.11C', '47.11D', '47.11E', '47.11F',
'47.19A', '47.19B', '47.21Z', '47.22Z', '47.23Z', '47.24Z', '47.25Z', '47.26Z', '47.29Z', '47.30Z',
'47.41Z', '47.42Z', '47.43Z', '47.51Z', '47.52A', '47.52B', '47.53Z', '47.54Z', '47.59A', '47.59B',
'47.61Z', '47.62Z', '47.63Z', '47.64Z', '47.65Z', '47.71Z', '47.72A', '47.72B', '47.73Z', '47.74Z',
'47.75Z', '47.76Z', '47.77Z', '47.78A', '47.78B', '47.78C', '47.79Z', '47.81Z', '47.82Z', '47.89Z',
'47.91A', '47.91B', '47.99A', '47.99B', '49.10Z', '49.20Z', '49.31Z', '49.32Z', '49.39A', '49.39B',
'49.39C', '49.41A', '49.41B', '49.41C', '49.42Z', '49.50Z', '50.10Z', '50.20Z', '50.30Z', '50.40Z',
'51.10Z', '51.21Z', '51.22Z', '52.10A', '52.10B', '52.21Z', '52.22Z', '52.23Z', '52.24A', '52.24B',
'52.29A', '52.29B', '53.10Z', '53.20Z', '55.10Z', '55.20Z', '55.30Z', '55.90Z', '56.10A', '56.10B',
'56.10C', '56.21Z', '56.29A', '56.29B', '56.30Z', '58.11Z', '58.12Z', '58.13Z', '58.14Z', '58.19Z',
'58.21Z', '58.29A', '58.29B', '58.29C', '59.11A', '59.11B', '59.11C', '59.12Z', '59.13A', '59.13B',
'59.14Z', '59.20Z', '60.10Z', '60.20A', '60.20B', '61.10Z', '61.20Z', '61.30Z', '61.90Z', '62.01Z',
'62.02A', '62.02B', '62.03Z', '62.09Z', '63.11Z', '63.12Z', '63.91Z', '63.99Z', '64.11Z', '64.19Z',
'64.20Z', '64.30Z', '64.91Z', '64.92Z', '64.99Z', '65.11Z', '65.12Z', '65.20Z', '65.30Z', '66.11Z',
'66.12Z', '66.19A', '66.19B', '66.21Z', '66.22Z', '66.29Z', '66.30Z', '68.10Z', '68.20A', '68.20B',
'68.31Z', '68.32A', '68.32B', '69.10Z', '69.20Z', '70.10Z', '70.21Z', '70.22Z', '71.11Z', '71.12A',
'71.12B', '71.20A', '71.20B', '72.11Z', '72.19Z', '72.20Z', '73.11Z', '73.12Z', '73.20Z', '74.10Z',
'74.20Z', '74.30Z', '74.90A', '74.90B', '75.00Z', '77.11A', '77.11B', '77.12Z', '77.21Z', '77.22Z',
'77.29Z', '77.31Z', '77.32Z', '77.33Z', '77.34Z', '77.35Z', '77.39Z', '77.40Z', '78.10Z', '78.20Z',
'78.30Z', '79.11Z', '79.12Z', '79.90Z', '80.10Z', '80.20Z', '80.30Z', '81.10Z', '81.21Z', '81.22Z',
'81.29A', '81.29B', '81.30Z', '82.11Z', '82.19Z', '82.20Z', '82.30Z', '82.91Z', '82.92Z', '82.99Z',
'84.11Z', '84.12Z', '84.13Z', '84.21Z', '84.22Z', '84.23Z', '84.24Z', '84.25Z', '84.30A', '84.30B',
'84.30C', '85.10Z', '85.20Z', '85.31Z', '85.32Z', '85.41Z', '85.42Z', '85.51Z', '85.52Z', '85.53Z',
'85.59A', '85.59B', '85.60Z', '86.10Z', '86.21Z', '86.22A', '86.22B', '86.22C', '86.23Z', '86.90A',
'86.90B', '86.90C', '86.90D', '86.90E', '86.90F', '87.10A', '87.10B', '87.10C', '87.20A', '87.20B',
'87.30A', '87.30B', '87.90A', '87.90B', '88.10A', '88.10B', '88.10C', '88.91A', '88.91B', '88.99A',
'88.99B', '90.01Z', '90.02Z', '90.03A', '90.03B', '90.04Z', '91.01Z', '91.02Z', '91.03Z', '91.04Z',
'92.00Z', '93.11Z', '93.12Z', '93.13Z', '93.19Z', '93.21Z', '93.29Z', '94.11Z', '94.12Z', '94.20Z',
'94.91Z', '94.92Z', '94.99Z', '95.11Z', '95.12Z', '95.21Z', '95.22Z', '95.23Z', '95.24Z', '95.25Z',
'95.29Z', '96.01A', '96.01B', '96.02A', '96.02B', '96.03Z', '96.04Z', '96.09Z', '97.00Z', '98.10Z',
'98.20Z', '99.00Z',
]
# Plage de divisions (2 premiers chiffres du code NAF) par section -- permet de
# limiter le repli par code NAF precis (3e niveau) aux seuls codes de la section
# en cours, au lieu de retester les 732 codes en entier a chaque section plafonnee
# (bug corrige le 13/09/2026 : provoquait un blocage de plusieurs heures sur les
# grandes villes, ex. Toulouse, ou plusieurs sections restent plafonnees).
SECTION_DIVISIONS = {
"A": range(1, 4), "B": range(5, 10), "C": range(10, 34), "D": range(35, 36),
"E": range(36, 40), "F": range(41, 44), "G": range(45, 48), "H": range(49, 54),
"I": range(55, 57), "J": range(58, 64), "K": range(64, 67), "L": range(68, 69),
"M": range(69, 76), "N": range(77, 83), "O": range(84, 85), "P": range(85, 86),
"Q": range(86, 89), "R": range(90, 94), "S": range(94, 97), "T": range(97, 99),
"U": range(99, 100),
}
def codes_for_section(section):
divisions = SECTION_DIVISIONS[section]
return [c for c in NAF_CODES if int(c[:2]) in divisions]
CAP = 10000
PAGE_SIZE = 25
RATE_DELAY = 0.15 # ~6-7 req/s, sous le quota documente de l'API
DDL_PHASE1 = """
CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` (
`ENT_Siren` char(9) NOT NULL,
`ENT_NomComplet` varchar(255) DEFAULT NULL,
`ENT_NomRaisonSociale` varchar(255) DEFAULT NULL,
`ENT_Sigle` varchar(50) DEFAULT NULL,
`ENT_AdresseSiege` varchar(255) DEFAULT NULL,
`ENT_CodePostal` varchar(20) DEFAULT NULL,
`ENT_Commune` varchar(100) DEFAULT NULL,
`ENT_CodeCommune` char(5) DEFAULT NULL,
`ENT_Departement` char(3) DEFAULT NULL,
`ENT_Region` char(2) DEFAULT NULL,
`ENT_DateCreation` date DEFAULT NULL,
`ENT_EtatAdministratif` char(1) DEFAULT NULL,
`ENT_DateFermeture` date DEFAULT NULL,
`ENT_ActivitePrincipale` varchar(10) DEFAULT NULL,
`ENT_SectionActivite` char(1) DEFAULT NULL,
`ENT_NatureJuridique` varchar(10) DEFAULT NULL,
`ENT_CategorieJuridique` enum('Privee','Publique','Association') NOT NULL DEFAULT 'Privee',
`ENT_CategorieEntreprise` varchar(5) DEFAULT NULL,
`ENT_TrancheEffectifSalarie` varchar(3) DEFAULT NULL,
`ENT_AnneeEffectif` year DEFAULT NULL,
`ENT_DirigeantNom` varchar(150) DEFAULT NULL,
`ENT_DirigeantPrenoms` varchar(150) DEFAULT NULL,
`ENT_DirigeantQualite` varchar(255) DEFAULT NULL,
`ENT_FinancesAnnee` year DEFAULT NULL,
`ENT_FinancesCa` bigint DEFAULT NULL,
`ENT_FinancesResultatNet` bigint DEFAULT NULL,
`ENT_StatutQualification` enum('a_qualifier','qualifiee','rejetee','contactee') NOT NULL DEFAULT 'a_qualifier',
`ENT_CanalProspection` enum('AEvaluer','Ecartee','Email','Courrier','TelephoneRobot') NOT NULL DEFAULT 'AEvaluer',
`ENT_Phase2` varchar(1) NOT NULL DEFAULT 'N',
`ENT_ScoreCiblage` tinyint DEFAULT NULL,
`ENT_Notes` text,
`ENT_DateImport` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ENT_DateMajInsee` datetime DEFAULT NULL,
`ENT_ReponseBruteJson` json DEFAULT NULL,
PRIMARY KEY (`ENT_Siren`),
KEY `idx_commune` (`ENT_CodeCommune`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8
"""
DDL_PHASE2 = """
CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase2` (
`ENT_Siren` char(9) NOT NULL,
`ENT_SiteWeb` varchar(255) DEFAULT NULL,
`ENT_Mail` varchar(255) DEFAULT NULL,
`ENT_Telephone` varchar(20) DEFAULT NULL,
`ENT_Portable` varchar(20) DEFAULT NULL,
PRIMARY KEY (`ENT_Siren`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
"""
DDL_PHASE3 = """
CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase3` (
`ENT_Siren` char(9) NOT NULL,
`ENT_FinancesAnnee` year NOT NULL,
`ENT_FinancesCa` bigint DEFAULT NULL,
`ENT_FinancesResultatNet` bigint DEFAULT NULL,
`ENT_DateMaj` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ENT_Siren`, `ENT_FinancesAnnee`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
"""
DDL_COMMUNES = """
CREATE TABLE IF NOT EXISTS `ENT_Communes` (
`COM_CodeInsee` char(5) NOT NULL,
`COM_CodePostal` varchar(100) DEFAULT NULL,
`COM_Ville` varchar(100) NOT NULL,
`COM_Departement` char(3) NOT NULL,
`COM_Latitude` decimal(10,6) DEFAULT NULL,
`COM_Longitude` decimal(10,6) DEFAULT NULL,
PRIMARY KEY (`COM_CodeInsee`),
KEY `idx_departement` (`COM_Departement`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
DDL_DEPARTEMENTS = """
CREATE TABLE IF NOT EXISTS `ENT_Departements` (
`DEP_Code` char(3) NOT NULL,
`DEP_Nom` varchar(100) NOT NULL,
`DEP_NbEntreprises` int NOT NULL DEFAULT 0,
`DEP_DateExtractionInitiale` datetime DEFAULT NULL,
`DEP_DateDerniereSyncFormalites` datetime DEFAULT NULL,
PRIMARY KEY (`DEP_Code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
DDL_CONFIG = """
CREATE TABLE IF NOT EXISTS `ENT_Config` (
`CFG_Cle` varchar(100) NOT NULL,
`CFG_Valeur` varchar(255) DEFAULT NULL,
PRIMARY KEY (`CFG_Cle`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
def log(msg):
print(msg, file=sys.stderr, flush=True)
def http_get_json(url, retries=8):
# Durci le 17/09/2026 : la version precedente ne rattrapait que
# urllib.error.URLError/TimeoutError, avec un backoff court (1-5s) --
# insuffisant face a deux incidents reels constates le meme jour :
# http.client.RemoteDisconnected (pas une URLError, jamais rattrapee,
# a fait planter le departement 74) et HTTP 429 Too Many Requests
# soutenu (deux extractions tournant par erreur en parallele ont fait
# plafonner l'API, cascadant sur 3 departements avant plantage). Meme
# esprit que le durcissement deja fait sur inpi_bilans_backfill.py.
for attempt in range(retries):
try:
with urllib.request.urlopen(url, timeout=20) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 429:
if attempt == retries - 1:
raise
retry_after = e.headers.get("Retry-After") if e.headers else None
wait = int(retry_after) if retry_after and retry_after.isdigit() else min(10 * (2 ** attempt), 300)
log(f" !! 429 Too Many Requests -- pause de {wait}s avant nouvelle tentative "
f"({attempt + 1}/{retries})")
time.sleep(wait)
continue
if attempt == retries - 1:
raise
log(f" retry apres erreur HTTP ({e})")
time.sleep(2 + attempt)
except (urllib.error.URLError, TimeoutError, http.client.HTTPException,
ConnectionError) as e:
if attempt == retries - 1:
raise
log(f" retry apres erreur reseau ({e})")
time.sleep(2 + attempt)
# Paris, Lyon et Marseille (PLM) : dans les donnees Sirene, le siege d'une
# entreprise de ces trois villes est quasi toujours code au niveau de
# l'arrondissement (75101-75120, 69381-69389, 13201-13216), jamais au code
# commune englobant (75056, 69123, 13055) -- verifie empiriquement le 14
# septembre 2026 sur Marseille : code_commune=13055 ne renvoie qu'une dizaine
# de resultats, alors que chaque arrondissement (13201 a 13216) est a lui
# seul plafonne ou proche du plafond de 10 000. Le code englobant est donc
# remplace par la liste de ses arrondissements avant extraction.
COMMUNES_ARRONDISSEMENTS = {"75": "75056", "69": "69123", "13": "13055"}
def fetch_arrondissements_geo(dep):
url = "https://geo.api.gouv.fr/communes?" + urllib.parse.urlencode(
{"codeDepartement": dep, "type": "arrondissement-municipal",
"fields": "code,nom,centre,codesPostaux,departement"}
)
return http_get_json(url)
def fetch_communes_geo(dep):
url = API_GEO.format(dep=dep) + "?" + urllib.parse.urlencode(
{"fields": "code,nom,centre,codesPostaux,departement"}
)
communes = http_get_json(url)
code_englobant = COMMUNES_ARRONDISSEMENTS.get(dep)
if code_englobant:
communes = [c for c in communes if c["code"] != code_englobant]
communes.extend(fetch_arrondissements_geo(dep))
return communes
def fetch_departement_nom(dep):
url = f"https://geo.api.gouv.fr/departements/{dep}?fields=code,nom"
data = http_get_json(url)
return data.get("nom", dep)
def upsert_departement(conn, dep, nom, nb_entreprises):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO ENT_Departements (DEP_Code, DEP_Nom, DEP_NbEntreprises, DEP_DateExtractionInitiale) "
"VALUES (%s, %s, %s, NOW()) "
"ON DUPLICATE KEY UPDATE DEP_Nom=VALUES(DEP_Nom), DEP_NbEntreprises=VALUES(DEP_NbEntreprises)",
(dep, nom, nb_entreprises),
)
NON_DIFFUSIBLE = "[NON-DIFFUSIBLE]"
def clean(v):
"""Les entites en diffusion partielle/non diffusible (RGPD) renvoient le
sentinel textuel [NON-DIFFUSIBLE] sur de nombreux champs (adresse, code postal,
coordonnees, nom/prenoms du dirigeant...). On le convertit en NULL plutot que
de le stocker (et ca evite aussi de depasser la taille des colonnes typees)."""
return None if v == NON_DIFFUSIBLE else v
def derive_categorie_juridique(nature_juridique):
if not nature_juridique:
return "Privee"
first = nature_juridique[0]
if first in ("7", "8"):
return "Publique"
if first == "9":
return "Association"
return "Privee"
def map_entreprise(r):
siege = r.get("siege") or {}
dirigeants = r.get("dirigeants") or []
dirigeant = dirigeants[0] if dirigeants else {}
finances = r.get("finances") or {}
finances_annee = max(finances.keys()) if finances else None
fin = finances.get(finances_annee, {}) if finances_annee else {}
return {
"ENT_Siren": r.get("siren"),
"ENT_NomComplet": clean(r.get("nom_complet")),
"ENT_NomRaisonSociale": clean(r.get("nom_raison_sociale")),
"ENT_Sigle": clean(r.get("sigle")),
"ENT_AdresseSiege": clean(siege.get("adresse")),
"ENT_CodePostal": clean(siege.get("code_postal")),
"ENT_Commune": clean(siege.get("libelle_commune")),
"ENT_CodeCommune": clean(siege.get("commune")),
"ENT_Departement": clean(siege.get("departement")),
"ENT_Region": clean(siege.get("region")),
"ENT_DateCreation": clean(r.get("date_creation")),
"ENT_EtatAdministratif": clean(r.get("etat_administratif")),
"ENT_DateFermeture": clean(r.get("date_fermeture")),
"ENT_ActivitePrincipale": clean(r.get("activite_principale")),
"ENT_SectionActivite": clean(r.get("section_activite_principale")),
"ENT_NatureJuridique": clean(r.get("nature_juridique")),
"ENT_CategorieJuridique": derive_categorie_juridique(clean(r.get("nature_juridique"))),
"ENT_CategorieEntreprise": clean(r.get("categorie_entreprise")),
"ENT_TrancheEffectifSalarie": clean(r.get("tranche_effectif_salarie")),
"ENT_AnneeEffectif": clean(r.get("annee_tranche_effectif_salarie")),
"ENT_DirigeantNom": clean(dirigeant.get("nom")),
"ENT_DirigeantPrenoms": clean(dirigeant.get("prenoms")),
"ENT_DirigeantQualite": clean(dirigeant.get("qualite")),
"ENT_FinancesAnnee": finances_annee,
"ENT_FinancesCa": fin.get("ca"),
"ENT_FinancesResultatNet": fin.get("resultat_net"),
"ENT_ReponseBruteJson": json.dumps(r, ensure_ascii=False),
}
def probe_total(params):
p = dict(params, per_page=1)
data = http_get_json(API_SEARCH + "?" + urllib.parse.urlencode(p))
time.sleep(RATE_DELAY)
return data.get("total_results", 0)
def paginate_all(params, seen):
p = dict(params, per_page=PAGE_SIZE)
page = 1
out = []
while True:
p["page"] = page
data = http_get_json(API_SEARCH + "?" + urllib.parse.urlencode(p))
time.sleep(RATE_DELAY)
results = data.get("results", [])
if not results:
break
for r in results:
siren = r.get("siren")
if siren and siren not in seen:
seen.add(siren)
out.append(r)
total_pages = data.get("total_pages", page)
if page >= total_pages or page >= 400: # 400*25 = 10000, garde-fou
break
page += 1
return out
def fetch_entreprises_for_filter(base, seen, label):
"""Cascade de repli generique (section NAF puis code NAF precis si
plafonnee a CAP resultats), utilisee aussi bien pour un filtre par
commune que, depuis le 18/09/2026, par departement entier (cas 2A/2B,
cf. fetch_departement_entreprises_direct)."""
total = probe_total(base)
if total < CAP:
return paginate_all(base, seen)
log(f" {label} plafonnee ({total}), repli par section NAF")
out = []
for section in NAF_SECTIONS:
sec_params = dict(base, section_activite_principale=section)
sec_total = probe_total(sec_params)
if sec_total == 0:
continue
if sec_total < CAP:
out.extend(paginate_all(sec_params, seen))
continue
# 3e niveau de repli : la section a elle seule reste plafonnee (cas rare,
# ex. Rennes/section L). On decoupe par code NAF precis (732 codes possibles) --
# un code implique deja sa section, donc on l'utilise seul, sans le combiner
# au filtre de section.
log(f" {label} section {section} encore plafonnee ({sec_total}), "
f"repli par code NAF precis")
for code in codes_for_section(section):
code_params = dict(base, activite_principale=code)
code_total = probe_total(code_params)
if code_total == 0:
continue
if code_total >= CAP:
log(f" !! {label} code {code} encore plafonnee "
f"({code_total}) -- a decouper manuellement (ex: par tranche_effectif_salarie)")
out.extend(paginate_all(code_params, seen))
return out
def fetch_commune_entreprises(code_commune, seen):
base = {"code_commune": code_commune, "etat_administratif": "A"}
return fetch_entreprises_for_filter(base, seen, f"commune {code_commune}")
def fetch_departement_entreprises_direct(dep, seen):
"""Repli specifique aux departements dont le code commune INSEE n'est pas
purement numerique (2A, 2B -- Corse) : le parametre `code_commune` de
l'API recherche-entreprises echoue silencieusement (0 resultat, HTTP 200,
aucune erreur) sur ces codes alphanumeriques, verifie empiriquement le
18/09/2026 (2A004/2B033 -> 0 alors que 01004 -> 6124). Le parametre
`departement`, lui, fonctionne normalement -- on interroge donc le
departement entier d'un coup, avec la meme cascade de repli que pour une
commune plafonnee."""
base = {"departement": dep, "etat_administratif": "A"}
return fetch_entreprises_for_filter(base, seen, f"departement {dep}")
def upsert_entreprise(conn, table, row):
cols = list(row.keys())
placeholders = ", ".join(["%s"] * len(cols))
colnames = ", ".join(f"`{c}`" for c in cols)
updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols if c != "ENT_Siren")
sql = (
f"INSERT INTO `{table}` ({colnames}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}, `ENT_DateMajInsee`=NOW()"
)
with conn.cursor() as cur:
cur.execute(sql, list(row.values()))
def upsert_finances(conn, table, row):
if row.get("ENT_FinancesAnnee") is None:
return
fin = {
"ENT_Siren": row["ENT_Siren"],
"ENT_FinancesAnnee": row["ENT_FinancesAnnee"],
"ENT_FinancesCa": row["ENT_FinancesCa"],
"ENT_FinancesResultatNet": row["ENT_FinancesResultatNet"],
}
cols = list(fin.keys())
placeholders = ", ".join(["%s"] * len(cols))
colnames = ", ".join(f"`{c}`" for c in cols)
updates = ", ".join(
f"`{c}`=VALUES(`{c}`)" for c in cols if c not in ("ENT_Siren", "ENT_FinancesAnnee")
)
sql = (
f"INSERT INTO `{table}` ({colnames}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}"
)
with conn.cursor() as cur:
cur.execute(sql, list(fin.values()))
def ensure_tables(conn, dep):
with conn.cursor() as cur:
cur.execute(DDL_PHASE1.format(dep=dep))
cur.execute(DDL_PHASE2.format(dep=dep))
cur.execute(DDL_PHASE3.format(dep=dep))
cur.execute(DDL_COMMUNES)
cur.execute(DDL_DEPARTEMENTS)
cur.execute(DDL_CONFIG)
def ensure_communes(conn, dep, communes_geo):
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM ENT_Communes WHERE COM_Departement=%s", (dep,))
(count,) = cur.fetchone()
if count > 0:
log(f" ENT_Communes deja alimentee pour le {dep} ({count} communes), on ne rejoue pas")
return
rows = []
for c in communes_geo:
lon, lat = c["centre"]["coordinates"]
cp = ",".join(c.get("codesPostaux", []))
dep_code = c["departement"]["code"] if isinstance(c.get("departement"), dict) else c.get("departement")
rows.append((c["code"], cp, c["nom"], dep_code, lat, lon))
cur.executemany(
"INSERT INTO ENT_Communes "
"(COM_CodeInsee, COM_CodePostal, COM_Ville, COM_Departement, COM_Latitude, COM_Longitude) "
"VALUES (%s,%s,%s,%s,%s,%s)",
rows,
)
log(f" ENT_Communes : {len(rows)} communes inserees pour le {dep}")
def run_department(conn, dep):
log(f"=== Departement {dep} ===")
ensure_tables(conn, dep)
communes_geo = fetch_communes_geo(dep)
log(f" {len(communes_geo)} communes (source geo.api.gouv.fr)")
ensure_communes(conn, dep, communes_geo)
table1 = f"Entreprises{dep}Phase1"
table3 = f"Entreprises{dep}Phase3"
seen = set()
total_upserts = 0
total_finances = 0
def store(results):
nonlocal total_upserts, total_finances
for r in results:
row = map_entreprise(r)
if row["ENT_Siren"]:
upsert_entreprise(conn, table1, row)
total_upserts += 1
if row["ENT_FinancesAnnee"] is not None:
upsert_finances(conn, table3, row)
total_finances += 1
if not dep.isdigit():
# Code commune INSEE alphanumerique (2A, 2B) : le filtre code_commune
# de l'API echoue silencieusement, on interroge le departement entier
# d'un coup (cf. fetch_departement_entreprises_direct).
results = fetch_departement_entreprises_direct(dep, seen)
store(results)
log(f" departement {dep} (requete directe) : {len(results)} entreprises")
else:
for c in communes_geo:
results = fetch_commune_entreprises(c["code"], seen)
store(results)
if results:
log(f" {c['nom']} ({c['code']}) : {len(results)} entreprises")
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM `{table1}`")
(nb_total,) = cur.fetchone()
nom_dep = fetch_departement_nom(dep)
upsert_departement(conn, dep, nom_dep, nb_total)
log(f"Departement {dep} termine : {total_upserts} entreprises upsertees dans {table1}, "
f"{total_finances} lignes financieres upsertees dans {table3} (total {nb_total} -- "
f"ENT_Departements mise a jour)")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--departements", required=True,
help="Liste separee par des virgules, ex: 56,17,64")
ap.add_argument("--db-host", default=os.environ.get("ENTREPRISES_DB_HOST", "mysql2"))
ap.add_argument("--db-port", type=int, default=int(os.environ.get("ENTREPRISES_DB_PORT", "3306")))
ap.add_argument("--db-user", default=os.environ.get("ENTREPRISES_DB_USER", "crm_user"))
ap.add_argument("--db-pass", default=os.environ.get("ENTREPRISES_DB_PASS"))
ap.add_argument("--db-name", default=os.environ.get("ENTREPRISES_DB_NAME", "ENTREPRISES"))
args = ap.parse_args()
if not args.db_pass:
ap.error("mot de passe requis : --db-pass ou variable d'environnement ENTREPRISES_DB_PASS")
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
conn = pymysql.connect(
host=args.db_host, port=args.db_port, user=args.db_user, password=args.db_pass,
database=args.db_name, charset="utf8mb4", autocommit=True,
)
try:
for dep in departements:
run_department(conn, dep)
finally:
conn.close()
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* ASPIC — sites_web.php
*
* Endpoint interne (11 septembre 2026, chantier Compétences/Missions) —
* gestion des sites créés par un collaborateur en mission (voir
* mission_agent.php). Volontairement séparé de ce dernier : la publication
* est une décision humaine explicite, jamais un outil laissé entre les mains
* du modèle (même discipline que la clôture d'une mission, jamais calculée
* automatiquement — voir Réflexions stratégiques section 18).
*
* GET : liste les sites (brouillon/publié) avec leurs fichiers.
* POST { "action": "publier", "SIT_Incr": 3 } : copie les fichiers du
* brouillon (hors racine web, /var/www/sites_brouillons) vers le dossier
* réellement servi (/var/www/html/sites_publies/{SIT_CheminDossier}/),
* marque SIT_Statut='publie' et SIT_DatePublication. Le dossier de
* destination est d'abord vidé puis repeuplé UNIQUEMENT à partir des
* fichiers connus d'ASP_SitesWebFichiers — jamais une copie de dossier brute
* qui traînerait un fichier orphelin resté sur le disque.
*
* Une fois publié, le rattachement à un nom de domaine réel
* (SIT_NomDomaine + vhost Apache ou entrée dans npm-app-1) reste une étape
* d'infrastructure manuelle, faite par Jean-Marc — hors du périmètre de cet
* endpoint.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
const RACINE_BROUILLONS_SW = '/var/www/sites_brouillons';
const RACINE_PUBLIEES_SW = __DIR__ . '/sites_publies';
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
function supprimerDossierRecursif(string $dossier): void
{
if (!is_dir($dossier)) return;
$elements = array_diff(scandir($dossier), ['.', '..']);
foreach ($elements as $element) {
$chemin = $dossier . '/' . $element;
is_dir($chemin) ? supprimerDossierRecursif($chemin) : @unlink($chemin);
}
@rmdir($dossier);
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$sites = $pdo->query('SELECT * FROM ASP_SitesWeb ORDER BY SIT_DateCreation DESC')->fetchAll();
foreach ($sites as &$s) {
$stmt = $pdo->prepare('SELECT SWF_CheminRelatif, SWF_Taille, SWF_DateModification FROM ASP_SitesWebFichiers WHERE SWF_SiteIncr = ? ORDER BY SWF_CheminRelatif');
$stmt->execute([$s['SIT_Incr']]);
$s['fichiers'] = $stmt->fetchAll();
}
echo json_encode(['sites' => $sites]);
exit;
}
if ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$action = (string) ($data['action'] ?? '');
$sitIncr = (int) ($data['SIT_Incr'] ?? 0);
if ($action !== 'publier') {
http_response_code(400);
echo json_encode(['error' => 'Action inconnue (seule "publier" est supportée)']);
exit;
}
if ($sitIncr <= 0) {
http_response_code(400);
echo json_encode(['error' => 'SIT_Incr manquant ou invalide']);
exit;
}
$stmt = $pdo->prepare('SELECT * FROM ASP_SitesWeb WHERE SIT_Incr = ?');
$stmt->execute([$sitIncr]);
$site = $stmt->fetch();
if (!$site) {
http_response_code(404);
echo json_encode(['error' => 'Site introuvable']);
exit;
}
$stmt = $pdo->prepare('SELECT SWF_CheminRelatif FROM ASP_SitesWebFichiers WHERE SWF_SiteIncr = ?');
$stmt->execute([$sitIncr]);
$fichiers = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$fichiers) {
http_response_code(400);
echo json_encode(['error' => 'Ce site ne contient encore aucun fichier — rien à publier.']);
exit;
}
$dossierBrouillon = RACINE_BROUILLONS_SW . '/' . $site['SIT_CheminDossier'];
$dossierPublie = RACINE_PUBLIEES_SW . '/' . $site['SIT_CheminDossier'];
supprimerDossierRecursif($dossierPublie);
@mkdir($dossierPublie, 0777, true);
@chmod($dossierPublie, 0777);
foreach ($fichiers as $relatif) {
$source = realpath($dossierBrouillon . '/' . $relatif);
$racineBrouillonReelle = realpath($dossierBrouillon);
if ($source === false || $racineBrouillonReelle === false || !str_starts_with($source, $racineBrouillonReelle)) {
continue; // fichier référencé en base mais absent/anormal sur disque — ignoré, jamais bloquant pour les autres
}
$destination = $dossierPublie . '/' . $relatif;
@mkdir(dirname($destination), 0777, true);
@chmod(dirname($destination), 0777);
copy($source, $destination);
@chmod($destination, 0666);
}
$pdo->prepare("UPDATE ASP_SitesWeb SET SIT_Statut = 'publie', SIT_DatePublication = NOW() WHERE SIT_Incr = ?")
->execute([$sitIncr]);
echo json_encode(['success' => true, 'chemin_publie' => 'sites_publies/' . $site['SIT_CheminDossier']]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
+38
View File
@@ -105,6 +105,25 @@ function comparerDateNullEnPremier(?string $a, ?string $b): int
return strcmp($a, $b);
}
/**
* Capacité maximale de post-its "en attente" affichables sur le tableau de
* liège de l'écran de fond, par attributaire (voir journal de développement,
* section 28 — "bureau à l'ancienne"). Ajoutée le 2 septembre 2026, en amont
* du code réel du tableau : Jean-Marc a posé le principe avant d'avoir les
* pixels pour le calculer précisément ("mets-le où tu veux, il faudra le
* faire évoluer") — valeur ronde provisoire, à corriger dès que la taille
* réelle de la zone et celle d'un post-it seront posées à l'écran.
*
* Choix de principe explicite de Jean-Marc : PAS d'ascenseur/défilement sur
* le tableau, même si ça permettrait d'accepter plus de post-its — un vrai
* bureau a une capacité finie, et c'est justement cette contrainte physique
* qui pousse à traiter ses tâches plutôt qu'à les empiler indéfiniment. Une
* fois cette capacité atteinte, la création est refusée avec un message
* clair (voir POST ci-dessous) plutôt que de trouver un moyen d'entasser
* plus de post-its ou de faire défiler le tableau.
*/
const CAPACITE_TABLEAU_PAR_UTILISATEUR = 20;
try {
switch ($method) {
case 'GET':
@@ -167,6 +186,25 @@ try {
$todoUser = $_SESSION['USR_Code'];
}
// Capacité du tableau (voir CAPACITE_TABLEAU_PAR_UTILISATEUR
// ci-dessus) : ne s'applique qu'aux tâches qui vont réellement
// occuper un post-it "en attente" sur le bureau de $todoUser.
// Une tâche créée déjà réalisée (TDL_RealDate fourni dès la
// création, voir doc d'en-tête — permet de journaliser une tâche
// déjà faite) n'occupe jamais de place sur le tableau, donc
// n'est jamais bloquée par cette limite.
if (empty($data['TDL_RealDate'])) {
$stmtCompte = $pdo->prepare(
'SELECT COUNT(*) FROM ASP_ToDoList WHERE TDL_Actif = 1 AND TDL_ToDoUser = ? AND TDL_RealDate IS NULL'
);
$stmtCompte->execute([$todoUser]);
if ((int)$stmtCompte->fetchColumn() >= CAPACITE_TABLEAU_PAR_UTILISATEUR) {
http_response_code(409);
echo json_encode(['error' => 'Ton tableau est plein, tu ne peux plus ajouter de post-it !']);
exit;
}
}
// TDL_ToDoDate : incluse dans l'INSERT seulement si fournie par le
// client (permet de dater rétroactivement une tâche oubliée) —
// sinon on laisse MySQL appliquer DEFAULT CURRENT_TIMESTAMP
+27 -1
View File
@@ -22,9 +22,35 @@
*
* Endpoint public, sans session requise : consulté aussi depuis l'écran
* d'identification, avant toute connexion.
*
* Format à trois niveaux depuis le 6 septembre 2026 : V<majeur>.<code>.<conception>
* — sans zéro de tête sur aucun des trois nombres (pas de largeur fixe
* supposée par avance, quel que soit le nombre de sessions ou de sections
* atteint un jour).
* - <code> avance à chaque session avec du code réellement livré et poussé,
* jamais pour une session sans conséquence sur le code (voir plus haut).
* - <conception> reprend le numéro de la dernière section CRÉÉE dans
* "ASPIC - Réflexions stratégiques" (pas un complément apporté à une
* section déjà existante) — il avance donc indépendamment de toute
* évolution du code, y compris lors d'une session qui ne touche pas une
* ligne de code (voir réflexions stratégiques, section 14, née de ce
* changement de convention).
* - <majeur> change uniquement quand Jean-Marc juge qu'ASPIC a franchi un cap
* de maturité important — une décision, pas un calcul automatique. À cette
* occasion, <code> repart à 1 (V1.58 → V2.1, jamais V2.59) alors que
* <conception> continue sa suite normale sans saut ni retour à 1
* (V1.58.67 → V2.1.68) : franchir ce cap est lui-même consigné comme une
* nouvelle section des Réflexions stratégiques, donc ce compteur-là avance
* comme à chaque nouvelle section, sans rupture de continuité.
* Les trois compteurs sont volontairement indépendants, chacun fidèle à son
* seul objet : <code> répond à "quel code tourne réellement ici" (voir le
* bug du logo du 26 août 2026 ci-dessus), <conception> à "où en est la
* conception d'ASPIC", <majeur> à "quelle génération d'ASPIC est-ce" — à ne
* jamais fusionner en un seul compteur unique, sous peine de perdre la
* capacité à détecter un déploiement resté en retard.
*/
const ASPIC_VERSION = 'V1.007';
const ASPIC_VERSION = 'V1.23.33';
if (basename($_SERVER['SCRIPT_NAME']) === 'version.php') {
header('Content-Type: application/json; charset=utf-8');