Files
wevia-brain/modules/agentic/planner.php
2026-04-12 23:01:36 +02:00

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; }
}