'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]);