mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
532 lines
24 KiB
PHP
532 lines
24 KiB
PHP
<?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';
|
|
|
|
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 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,
|
|
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 Jean-Marc :\n\n{$experience}"
|
|
: '';
|
|
|
|
$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.";
|
|
|
|
$promptSysteme = <<<PROMPT
|
|
Tu es {$prenom}, en mission au sein de l'agence de Jean-Marc. Voici ton parcours et ta personnalité, à ton seul usage, jamais à réciter tel quel :
|
|
|
|
{$blocIdentite}{$blocExperience}
|
|
|
|
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 : {$mission['MIS_Titre']}
|
|
Objectif : {$mission['MIS_Objectif']}
|
|
|
|
Compétence mobilisée : {$mission['CMP_Libelle']} — {$mission['CMP_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) :
|
|
{$listeOutils}
|
|
|
|
{$contexteSite}
|
|
|
|
Règles strictes :
|
|
- Tu ne travailles JAMAIS que pour l'entreprise de Jean-Marc elle-même, jamais pour un client tiers de cette entreprise.
|
|
- Tu ne peux utiliser comme contenu que ce que Jean-Marc 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;
|
|
|
|
// -------- 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[Jean-Marc] $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]);
|