371 lines
14 KiB
PHP
Executable File
371 lines
14 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* WEVIA OPUS — Agentic Planner
|
|
*
|
|
* Transforme une demande complexe en plan d'exécution:
|
|
* 1. Décompose en sous-tâches
|
|
* 2. Identifie les dépendances
|
|
* 3. Assigne les outils/agents
|
|
* 4. Exécute séquentiellement ou en parallèle
|
|
* 5. Gère les erreurs et retry
|
|
* 6. Synthétise les résultats
|
|
*
|
|
* C'est le module qui fait de WEVIA un AGENT, pas juste un chatbot.
|
|
*/
|
|
|
|
class AgenticPlanner {
|
|
|
|
private string $ollamaUrl;
|
|
private array $availableTools;
|
|
private array $executionLog = [];
|
|
|
|
public function __construct(string $ollamaUrl = 'http://localhost:11434') {
|
|
$this->ollamaUrl = $ollamaUrl;
|
|
$this->registerTools();
|
|
}
|
|
|
|
private function registerTools(): void {
|
|
$this->availableTools = [
|
|
'web_fetch' => [
|
|
'description' => 'Récupère le contenu d\'une page web',
|
|
'params' => ['url' => 'string'],
|
|
'returns' => 'HTML/text content',
|
|
'cost' => 'medium',
|
|
'reliability' => 0.9
|
|
],
|
|
'database_query' => [
|
|
'description' => 'Exécute une requête SELECT sur PostgreSQL',
|
|
'params' => ['query' => 'string', 'database' => 'string'],
|
|
'returns' => 'Array of rows',
|
|
'cost' => 'low',
|
|
'reliability' => 0.99
|
|
],
|
|
'execute_code' => [
|
|
'description' => 'Exécute du code Python/Bash/PHP',
|
|
'params' => ['language' => 'string', 'code' => 'string'],
|
|
'returns' => 'stdout + stderr',
|
|
'cost' => 'medium',
|
|
'reliability' => 0.85
|
|
],
|
|
'file_read' => [
|
|
'description' => 'Lit le contenu d\'un fichier',
|
|
'params' => ['path' => 'string'],
|
|
'returns' => 'File content',
|
|
'cost' => 'low',
|
|
'reliability' => 0.99
|
|
],
|
|
'file_write' => [
|
|
'description' => 'Écrit dans un fichier',
|
|
'params' => ['path' => 'string', 'content' => 'string'],
|
|
'returns' => 'success/failure',
|
|
'cost' => 'low',
|
|
'reliability' => 0.99
|
|
],
|
|
'llm_generate' => [
|
|
'description' => 'Génère du texte avec un LLM local',
|
|
'params' => ['model' => 'string', 'prompt' => 'string'],
|
|
'returns' => 'Generated text',
|
|
'cost' => 'high',
|
|
'reliability' => 0.95
|
|
],
|
|
'embedding' => [
|
|
'description' => 'Génère un embedding vectoriel',
|
|
'params' => ['text' => 'string'],
|
|
'returns' => 'Vector (768 dimensions)',
|
|
'cost' => 'low',
|
|
'reliability' => 0.99
|
|
],
|
|
'semantic_search' => [
|
|
'description' => 'Recherche sémantique dans la KB',
|
|
'params' => ['query' => 'string', 'limit' => 'int'],
|
|
'returns' => 'Relevant documents',
|
|
'cost' => 'medium',
|
|
'reliability' => 0.9
|
|
],
|
|
'send_email' => [
|
|
'description' => 'Envoie un email via PowerMTA ou O365',
|
|
'params' => ['to' => 'string', 'subject' => 'string', 'body' => 'string'],
|
|
'returns' => 'message_id',
|
|
'cost' => 'medium',
|
|
'reliability' => 0.95
|
|
],
|
|
'ssh_command' => [
|
|
'description' => 'Exécute une commande SSH sur un serveur distant',
|
|
'params' => ['host' => 'string', 'command' => 'string'],
|
|
'returns' => 'stdout',
|
|
'cost' => 'medium',
|
|
'reliability' => 0.85
|
|
]
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Décompose une tâche complexe en plan d'exécution
|
|
*/
|
|
public function plan(string $task): array {
|
|
$prompt = "Tu es un planificateur de tâches expert. Décompose cette tâche en sous-tâches exécutables.\n\n"
|
|
. "Tâche: $task\n\n"
|
|
. "Outils disponibles: " . implode(', ', array_keys($this->availableTools)) . "\n\n"
|
|
. "Retourne UNIQUEMENT un JSON avec cette structure:\n"
|
|
. "{\n"
|
|
. " \"goal\": \"objectif final\",\n"
|
|
. " \"steps\": [\n"
|
|
. " {\n"
|
|
. " \"id\": 1,\n"
|
|
. " \"action\": \"description de l'action\",\n"
|
|
. " \"tool\": \"nom_de_l_outil\",\n"
|
|
. " \"params\": {\"param1\": \"valeur1\"},\n"
|
|
. " \"depends_on\": [],\n"
|
|
. " \"retry_on_failure\": true,\n"
|
|
. " \"max_retries\": 2\n"
|
|
. " }\n"
|
|
. " ],\n"
|
|
. " \"estimated_duration_seconds\": 30,\n"
|
|
. " \"risk_level\": \"low|medium|high\"\n"
|
|
. "}";
|
|
|
|
$response = $this->callOllama('deepseek-r1:32b', $prompt);
|
|
|
|
// Parse le plan
|
|
$response = preg_replace('/^```json\s*/', '', trim($response));
|
|
$response = preg_replace('/\s*```$/', '', $response);
|
|
// Extraire le JSON si entouré de <think> tags
|
|
if (preg_match('/\{[\s\S]*\}/', $response, $jsonMatch)) {
|
|
$response = $jsonMatch[0];
|
|
}
|
|
|
|
$plan = json_decode($response, true);
|
|
if (!$plan || !isset($plan['steps'])) {
|
|
return ['error' => 'Failed to parse plan', 'raw' => $response];
|
|
}
|
|
|
|
// Valider le plan
|
|
$plan['validation'] = $this->validatePlan($plan);
|
|
|
|
return $plan;
|
|
}
|
|
|
|
/**
|
|
* Valide un plan d'exécution
|
|
*/
|
|
private function validatePlan(array $plan): array {
|
|
$issues = [];
|
|
|
|
foreach ($plan['steps'] ?? [] as $step) {
|
|
// Vérifier que l'outil existe
|
|
$tool = $step['tool'] ?? '';
|
|
if (!isset($this->availableTools[$tool])) {
|
|
$issues[] = "Step {$step['id']}: outil '$tool' inconnu";
|
|
}
|
|
|
|
// Vérifier les dépendances
|
|
$deps = $step['depends_on'] ?? [];
|
|
foreach ($deps as $dep) {
|
|
$found = false;
|
|
foreach ($plan['steps'] as $s) {
|
|
if ($s['id'] == $dep) { $found = true; break; }
|
|
}
|
|
if (!$found) {
|
|
$issues[] = "Step {$step['id']}: dépendance $dep inexistante";
|
|
}
|
|
}
|
|
|
|
// Vérifier les dépendances circulaires
|
|
if (in_array($step['id'], $deps)) {
|
|
$issues[] = "Step {$step['id']}: dépendance circulaire";
|
|
}
|
|
}
|
|
|
|
return [
|
|
'valid' => empty($issues),
|
|
'issues' => $issues,
|
|
'step_count' => count($plan['steps'] ?? []),
|
|
'has_parallel_steps' => $this->hasParallelSteps($plan)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Vérifie si le plan a des étapes parallélisables
|
|
*/
|
|
private function hasParallelSteps(array $plan): bool {
|
|
$deps = [];
|
|
foreach ($plan['steps'] ?? [] as $step) {
|
|
$deps[$step['id']] = $step['depends_on'] ?? [];
|
|
}
|
|
|
|
// Deux étapes sont parallélisables si elles ne dépendent pas l'une de l'autre
|
|
$steps = $plan['steps'] ?? [];
|
|
for ($i = 0; $i < count($steps); $i++) {
|
|
for ($j = $i + 1; $j < count($steps); $j++) {
|
|
$id1 = $steps[$i]['id'];
|
|
$id2 = $steps[$j]['id'];
|
|
if (!in_array($id1, $deps[$id2] ?? []) && !in_array($id2, $deps[$id1] ?? [])) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Exécute un plan step by step
|
|
*/
|
|
public function execute(array $plan): array {
|
|
if (!($plan['validation']['valid'] ?? false)) {
|
|
return ['error' => 'Plan invalide', 'issues' => $plan['validation']['issues'] ?? []];
|
|
}
|
|
|
|
$results = [];
|
|
$startTime = microtime(true);
|
|
|
|
foreach ($plan['steps'] as $step) {
|
|
$stepId = $step['id'];
|
|
|
|
// Vérifier que les dépendances sont résolues
|
|
foreach ($step['depends_on'] ?? [] as $dep) {
|
|
if (!isset($results[$dep]) || ($results[$dep]['status'] ?? '') !== 'success') {
|
|
$results[$stepId] = [
|
|
'status' => 'skipped',
|
|
'reason' => "Dépendance $dep non résolue"
|
|
];
|
|
continue 2;
|
|
}
|
|
}
|
|
|
|
// Exécuter avec retry
|
|
$maxRetries = ($step['retry_on_failure'] ?? false) ? ($step['max_retries'] ?? 2) : 0;
|
|
$attempt = 0;
|
|
$success = false;
|
|
|
|
while ($attempt <= $maxRetries && !$success) {
|
|
$attempt++;
|
|
$stepStart = microtime(true);
|
|
|
|
try {
|
|
$result = $this->executeStep($step, $results);
|
|
$result['duration_ms'] = round((microtime(true) - $stepStart) * 1000);
|
|
$result['attempt'] = $attempt;
|
|
$results[$stepId] = $result;
|
|
$success = ($result['status'] === 'success');
|
|
} catch (\Exception $e) {
|
|
$results[$stepId] = [
|
|
'status' => 'error',
|
|
'error' => $e->getMessage(),
|
|
'attempt' => $attempt,
|
|
'duration_ms' => round((microtime(true) - $stepStart) * 1000)
|
|
];
|
|
}
|
|
|
|
if (!$success && $attempt <= $maxRetries) {
|
|
usleep(500000 * $attempt); // Backoff: 0.5s, 1s, 1.5s...
|
|
}
|
|
}
|
|
|
|
$this->executionLog[] = [
|
|
'step_id' => $stepId,
|
|
'action' => $step['action'] ?? '',
|
|
'tool' => $step['tool'] ?? '',
|
|
'result' => $results[$stepId],
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
];
|
|
}
|
|
|
|
$totalDuration = round((microtime(true) - $startTime) * 1000);
|
|
|
|
// Synthèse
|
|
$successCount = count(array_filter($results, fn($r) => ($r['status'] ?? '') === 'success'));
|
|
$totalSteps = count($plan['steps']);
|
|
|
|
return [
|
|
'goal' => $plan['goal'] ?? '',
|
|
'results' => $results,
|
|
'summary' => [
|
|
'total_steps' => $totalSteps,
|
|
'success' => $successCount,
|
|
'failed' => $totalSteps - $successCount,
|
|
'success_rate' => round(($successCount / max(1, $totalSteps)) * 100, 1) . '%',
|
|
'total_duration_ms' => $totalDuration
|
|
],
|
|
'execution_log' => $this->executionLog
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Exécute une étape individuelle
|
|
*/
|
|
private function executeStep(array $step, array $previousResults): array {
|
|
$tool = $step['tool'] ?? '';
|
|
$params = $step['params'] ?? [];
|
|
|
|
// Résoudre les références aux résultats précédents
|
|
// Format: {{step_1.output}}
|
|
array_walk_recursive($params, function(&$value) use ($previousResults) {
|
|
if (is_string($value) && preg_match('/\{\{step_(\d+)\.(\w+)\}\}/', $value, $m)) {
|
|
$refStep = $m[1];
|
|
$refField = $m[2];
|
|
if (isset($previousResults[$refStep][$refField])) {
|
|
$value = $previousResults[$refStep][$refField];
|
|
}
|
|
}
|
|
});
|
|
|
|
switch ($tool) {
|
|
case 'file_read':
|
|
$path = $params['path'] ?? '';
|
|
if (!file_exists($path)) return ['status' => 'error', 'error' => "File not found: $path"];
|
|
return ['status' => 'success', 'output' => file_get_contents($path)];
|
|
|
|
case 'file_write':
|
|
$path = $params['path'] ?? '';
|
|
$content = $params['content'] ?? '';
|
|
$written = file_put_contents($path, $content);
|
|
return $written !== false ?
|
|
['status' => 'success', 'output' => "Written $written bytes to $path"] :
|
|
['status' => 'error', 'error' => "Failed to write to $path"];
|
|
|
|
case 'execute_code':
|
|
$lang = $params['language'] ?? 'bash';
|
|
$code = $params['code'] ?? '';
|
|
$output = shell_exec($code . ' 2>&1');
|
|
return ['status' => 'success', 'output' => $output];
|
|
|
|
case 'llm_generate':
|
|
$model = $params['model'] ?? 'llama3.1:8b';
|
|
$prompt = $params['prompt'] ?? '';
|
|
$response = $this->callOllama($model, $prompt);
|
|
return ['status' => 'success', 'output' => $response];
|
|
|
|
case 'database_query':
|
|
// Delégué à l'appelant via callback
|
|
return ['status' => 'pending', 'query' => $params['query'] ?? '', 'note' => 'Needs DB connection'];
|
|
|
|
default:
|
|
return ['status' => 'error', 'error' => "Tool '$tool' not implemented yet"];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appel Ollama
|
|
*/
|
|
private function callOllama(string $model, string $prompt): string {
|
|
$ch = curl_init("{$this->ollamaUrl}/api/generate");
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode([
|
|
'model' => $model, 'prompt' => $prompt,
|
|
'stream' => false, 'options' => ['temperature' => 0.3, 'num_predict' => 2048]
|
|
]),
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_TIMEOUT => 120
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
curl_close($ch);
|
|
$data = json_decode($resp, true);
|
|
return $data['response'] ?? '';
|
|
}
|
|
|
|
public function getAvailableTools(): array { return $this->availableTools; }
|
|
public function getExecutionLog(): array { return $this->executionLog; }
|
|
}
|