Files
html/api/multiagent-orchestrator.php
2026-04-22 04:30:07 +02:00

380 lines
20 KiB
PHP

<?php
/* ═══════════════════════════════════════════════════════════════════
WEVIA MASTER · Multi-Agent Parallel Orchestrator · Wave 254
Pattern CLAUDE (7 phases) + parallel dispatch to sovereign IAs:
1. THINKING · intent classification (natural language)
2. PLAN · which IAs to mobilize (parallel)
3. DISPATCH · curl_multi parallel to all agents
4. GROUND · merge live data (Paperclip + Scanner + Dark Scout + WePredict)
5. SYNTHESIZE · LLM merges all agent outputs
6. TESTS · validation (no hallucination check)
7. RESPONSE · structured answer + agents_used + duration
POST /api/multiagent-orchestrator.php
{"message":"...","session":"..."}
Returns streaming SSE with thinking/plan/dispatch/synthesize events
═══════════════════════════════════════════════════════════════════ */
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('Access-Control-Allow-Origin: *');
set_time_limit(35);
$t0 = microtime(true);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$message = trim($input['message'] ?? '');
$session = $input['session'] ?? 'mao-' . bin2hex(random_bytes(3));
$sse = isset($_GET['sse']) && $_GET['sse'] == '1';
if (!$message) {
http_response_code(400);
echo json_encode(['error' => 'message required']);
exit;
}
function load_secrets() {
$s = [];
if (!is_readable('/etc/weval/secrets.env')) return $s;
foreach (file('/etc/weval/secrets.env', FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) as $l) {
if (empty(trim($l))||$l[0]==='#') continue;
$p = strpos($l,'='); if ($p) $s[trim(substr($l,0,$p))] = trim(substr($l,$p+1)," \t\"'");
}
return $s;
}
function pg_c() { return @pg_connect('host=10.1.0.3 port=5432 dbname=paperclip user=admin password=admin123 connect_timeout=2'); }
// ═══════════════════════════════════════════════════════════════════
// PHASE 1: THINKING · intent classification
// ═══════════════════════════════════════════════════════════════════
function phase_thinking($msg) {
$lower = strtolower($msg);
$intents = [];
// Data queries
if (preg_match('/lead|prospect|client|pipeline/i', $msg)) $intents[] = 'paperclip';
if (preg_match('/solution|produit|scanner|roadmap|dev.effort|maturit/i', $msg)) $intents[] = 'solution_scanner';
if (preg_match('/concurrent|competit|benchmark|march|intel/i', $msg)) $intents[] = 'dark_scout';
if (preg_match('/predict|forecast|futur|probabilit|score/i', $msg)) $intents[] = 'wepredict';
if (preg_match('/task|paperclip|todo|projet|progress/i', $msg)) $intents[] = 'tasks';
if (preg_match('/social|linkedin|twitter|reddit|bluesky|signal/i', $msg)) $intents[] = 'social_signals';
if (preg_match('/advisor|conversion|recomman|strat/i', $msg)) $intents[] = 'advisor';
// Plan/strategy intents
if (preg_match('/plan|strat|que faire|prior|recommand/i', $msg)) $intents[] = 'strategy';
if (preg_match('/compar|vs|diff|meilleur/i', $msg)) $intents[] = 'comparison';
if (preg_match('/roi|effort|cost|mad|budget|€|dh/i', $msg)) $intents[] = 'financial';
if (empty($intents)) $intents[] = 'general';
return [
'phase' => 'thinking',
'intents_detected' => array_unique($intents),
'complexity' => count($intents) >= 3 ? 'high' : (count($intents) >= 2 ? 'medium' : 'low'),
'duration_ms' => 0,
];
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 2: PLAN · which agents to call in parallel
// ═══════════════════════════════════════════════════════════════════
function phase_plan($intents) {
$agent_map = [
'paperclip' => ['name'=>'Paperclip Agent', 'type'=>'db_query', 'icon'=>'📋'],
'solution_scanner' => ['name'=>'Solution Scanner', 'url'=>'http://127.0.0.1/api/solution-scanner.php?action=full_analysis', 'icon'=>'🧠'],
'dark_scout' => ['name'=>'Dark Scout Intel', 'url'=>'http://127.0.0.1/api/v83-dark-scout-enriched.php', 'icon'=>'🕵'],
'wepredict' => ['name'=>'WePredict Cockpit', 'url'=>'http://127.0.0.1/api/dsh-predict-api.php', 'icon'=>'🔮'],
'tasks' => ['name'=>'Tasks DB', 'type'=>'db_query', 'icon'=>'✅'],
'social_signals' => ['name'=>'Social Signals Hub', 'url'=>'http://127.0.0.1/api/social-signals-hub.php', 'icon'=>'📡'],
'advisor' => ['name'=>'Growth Advisor', 'url'=>'http://127.0.0.1/api/growth-conversion-advisor.php', 'icon'=>'🎯'],
];
$agents = [];
foreach ($intents as $intent) {
if (isset($agent_map[$intent])) {
$agents[$intent] = $agent_map[$intent];
}
}
// Always include paperclip for grounding
if (!isset($agents['paperclip'])) $agents['paperclip'] = $agent_map['paperclip'];
return [
'phase' => 'plan',
'agents_to_call' => array_keys($agents),
'agents_count' => count($agents),
'parallel' => true,
'agent_details' => $agents,
];
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 3: DISPATCH · parallel curl_multi + DB queries
// ═══════════════════════════════════════════════════════════════════
function phase_dispatch($agents) {
$t = microtime(true);
$results = [];
// HTTP agents via curl_multi (parallel)
$mh = curl_multi_init();
$handles = [];
foreach ($agents as $key => $info) {
if (isset($info['url'])) {
$ch = curl_init($info['url']);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>2, CURLOPT_USERAGENT=>'WEVIA-multiagent/1.0']);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
}
}
$running = null;
do { curl_multi_exec($mh, $running); curl_multi_select($mh, 0.1); } while ($running > 0);
foreach ($handles as $key => $ch) {
$raw = curl_multi_getcontent($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
$results[$key] = ['http'=>$code, 'data'=>@json_decode($raw, true), 'raw_size'=>strlen($raw ?: '')];
}
curl_multi_close($mh);
// DB agents (Paperclip, Tasks)
if (isset($agents['paperclip'])) {
$pg = pg_c();
if ($pg) {
$leads = ['total'=>0, 'top_industries'=>[], 'top_countries'=>[], 'top_leads'=>[], 'by_status'=>[]];
$r1 = @pg_query($pg, "SELECT COUNT(*) AS n, ROUND(AVG(mql_score)) AS avg_mql FROM weval_leads");
if ($r1) $leads['total'] = pg_fetch_assoc($r1);
$r2 = @pg_query($pg, "SELECT industry, COUNT(*) AS n, ROUND(AVG(mql_score)) AS avg_mql FROM weval_leads WHERE industry IS NOT NULL GROUP BY industry ORDER BY n DESC LIMIT 6");
if ($r2) while ($row = pg_fetch_assoc($r2)) $leads['top_industries'][] = $row;
$r3 = @pg_query($pg, "SELECT country, COUNT(*) AS n FROM weval_leads WHERE country IS NOT NULL GROUP BY country ORDER BY n DESC LIMIT 6");
if ($r3) while ($row = pg_fetch_assoc($r3)) $leads['top_countries'][] = $row;
$r4 = @pg_query($pg, "SELECT company, mql_score, industry, country, sql_qualified FROM weval_leads WHERE mql_score >= 85 ORDER BY mql_score DESC LIMIT 5");
if ($r4) while ($row = pg_fetch_assoc($r4)) $leads['top_leads'][] = $row;
$r5 = @pg_query($pg, "SELECT status, COUNT(*) AS n FROM weval_leads GROUP BY status");
if ($r5) while ($row = pg_fetch_assoc($r5)) $leads['by_status'][] = $row;
pg_close($pg);
$results['paperclip'] = ['http'=>200, 'data'=>$leads, 'raw_size'=>json_encode($leads)];
}
}
if (isset($agents['tasks'])) {
$pg = pg_c();
if ($pg) {
$tasks = ['total'=>0, 'by_status'=>[]];
$r1 = @pg_query($pg, "SELECT COUNT(*) AS n, SUM(estimated_mad) AS mad FROM weval_tasks");
if ($r1) $tasks['total'] = pg_fetch_assoc($r1);
$r2 = @pg_query($pg, "SELECT status, COUNT(*) AS n, SUM(estimated_mad) AS mad FROM weval_tasks GROUP BY status");
if ($r2) while ($row = pg_fetch_assoc($r2)) $tasks['by_status'][] = $row;
pg_close($pg);
$results['tasks'] = ['http'=>200, 'data'=>$tasks];
}
}
return [
'phase' => 'dispatch',
'results' => $results,
'agents_succeeded' => count(array_filter($results, function($r){return ($r['http']??0) >= 200 && ($r['http']??0) < 300;})),
'agents_failed' => count(array_filter($results, function($r){return ($r['http']??0) >= 400 || !($r['data']??null);})),
'duration_ms' => round((microtime(true) - $t) * 1000),
];
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 4: GROUND · build consolidated context
// ═══════════════════════════════════════════════════════════════════
function phase_ground($dispatch_results) {
$ctx = "DONNÉES LIVE WEVAL (agents appelés en parallèle, pas d'hallucination possible):\n\n";
$results = $dispatch_results['results'] ?? [];
if (!empty($results['paperclip']['data'])) {
$p = $results['paperclip']['data'];
$t = $p['total'] ?? [];
$ctx .= "📋 PAPERCLIP (48 leads DB):\n";
$ctx .= " · Total: " . ($t['n'] ?? '?') . " leads · avg MQL " . ($t['avg_mql'] ?? '?') . "\n";
if (!empty($p['top_industries'])) {
$ctx .= " · Industries: ";
foreach ($p['top_industries'] as $i) $ctx .= $i['industry'] . "(" . $i['n'] . ") ";
$ctx .= "\n";
}
if (!empty($p['top_leads'])) {
$ctx .= " · TOP leads MQL85+: ";
foreach ($p['top_leads'] as $tl) $ctx .= $tl['company'] . "(MQL" . $tl['mql_score'] . ") · ";
$ctx .= "\n";
}
}
if (!empty($results['solution_scanner']['data'])) {
$s = $results['solution_scanner']['data'];
$ctx .= "\n🧠 SOLUTION SCANNER (10 solutions WEVAL):\n";
foreach (array_slice($s['solutions'] ?? [], 0, 5) as $sol) {
$ctx .= " · " . $sol['name'] . " score " . $sol['winning_score'] . "/100 · " . $sol['decision'] . " · " . round($sol['mad_est']/1000) . "K MAD · maturité " . $sol['maturity'] . "%\n";
}
$sm = $s['summary'] ?? [];
$ctx .= " · Pipeline global: " . round(($sm['total_mad_pipeline'] ?? 0)/1000) . "K MAD · dev cost " . round(($sm['total_dev_cost_mad'] ?? 0)/1000) . "K · SHIP_IT=" . ($sm['ship_it']??0) . " DEV_SPRINT=" . ($sm['dev_sprint']??0) . "\n";
}
if (!empty($results['wepredict']['data'])) {
$w = $results['wepredict']['data'];
$ctx .= "\n🔮 WEPREDICT: load predicted_next_hour=" . ($w['load']['predicted_next_hour'] ?? '?') . " alert=" . ($w['load']['alert'] ? 'YES' : 'no') . "\n";
}
if (!empty($results['tasks']['data'])) {
$t = $results['tasks']['data'];
$ctx .= "\n✅ TASKS DB: " . ($t['total']['n'] ?? '?') . " tasks · " . round(($t['total']['mad'] ?? 0)/1000) . "K MAD total\n";
}
if (!empty($results['dark_scout']['data'])) {
$d = $results['dark_scout']['data'];
$ctx .= "\n🕵 DARK SCOUT: " . count($d['results'] ?? []) . " intel items\n";
}
if (!empty($results['social_signals']['data'])) {
$s = $results['social_signals']['data'];
$ctx .= "\n📡 SOCIAL SIGNALS: " . ($s['total_items'] ?? 0) . " items across " . count($s['channels'] ?? []) . " channels\n";
}
return [
'phase' => 'ground',
'context_chars' => strlen($ctx),
'context' => $ctx,
];
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 5: SYNTHESIZE · LLM merges agent outputs (cascade)
// ═══════════════════════════════════════════════════════════════════
function phase_synthesize($message, $context, $intents) {
$secrets = load_secrets();
$providers = [
['name'=>'Groq-Llama3.3', 'url'=>'https://api.groq.com/openai/v1/chat/completions', 'key'=>$secrets['GROQ_KEY']??'', 'model'=>'llama-3.3-70b-versatile'],
['name'=>'Cerebras-Llama3.3', 'url'=>'https://api.cerebras.ai/v1/chat/completions', 'key'=>$secrets['CEREBRAS_API_KEY']??'', 'model'=>'llama-3.3-70b'],
['name'=>'Mistral', 'url'=>'https://api.mistral.ai/v1/chat/completions', 'key'=>$secrets['MISTRAL_KEY']??'', 'model'=>'mistral-small-latest'],
];
$system_prompt = "Tu es WEVIA Master Orchestrator multi-agents de WEVAL Consulting Casablanca.\n\n" .
"Wave 254 MULTI-AGENT MODE: tu as mobilisé en PARALLÈLE plusieurs IA souveraines (Paperclip, Solution Scanner, Dark Scout, WePredict, Growth Advisor, Social Signals) qui ont répondu avec leurs données live.\n\n" .
"RÈGLES STRICTES:\n" .
"1. Utilise UNIQUEMENT les données live ci-dessous (JAMAIS inventer)\n" .
"2. Synthesize les outputs multi-agents en UNE réponse cohérente\n" .
"3. Cite les agents utilisés (ex: 'selon Solution Scanner...', 'Paperclip indique...')\n" .
"4. Langage naturel français, concis, actionnable\n" .
"5. Intents détectés: " . implode(', ', $intents) . "\n" .
"6. Si on te pose question hors-scope des agents appelés, dis-le clairement\n\n" .
$context;
$messages = [
['role'=>'system', 'content'=>$system_prompt],
['role'=>'user', 'content'=>$message]
];
$t = microtime(true);
foreach ($providers as $p) {
if (empty($p['key'])) continue;
$ch = curl_init($p['url']);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>15,
CURLOPT_HTTPHEADER=>['Content-Type: application/json', 'Authorization: Bearer '.$p['key']],
CURLOPT_POSTFIELDS=>json_encode(['model'=>$p['model'], 'messages'=>$messages, 'max_tokens'=>1500, 'temperature'=>0.2])
]);
$r = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300) {
$d = json_decode($r, true);
$text = $d['choices'][0]['message']['content'] ?? '';
if ($text) {
return [
'phase' => 'synthesize',
'response' => $text,
'provider' => $p['name'],
'duration_ms' => round((microtime(true) - $t) * 1000),
];
}
}
}
return ['phase' => 'synthesize', 'response' => 'Service LLM indisponible', 'provider' => 'none', 'duration_ms' => round((microtime(true) - $t) * 1000)];
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 6: TESTS · hallucination check
// ═══════════════════════════════════════════════════════════════════
function phase_tests($response, $context) {
$lower_resp = strtolower($response);
$tests = [];
// Anti-hallucination tests
$hallucinate_phrases = ["je n'ai pas d'accès", "je ne peux pas accéder", "pas d'accès direct", "i don't have access", "je ne connais pas"];
$hallucinations = [];
foreach ($hallucinate_phrases as $p) {
if (strpos($lower_resp, $p) !== false) $hallucinations[] = $p;
}
$tests['no_hallucination'] = empty($hallucinations);
$tests['hallucination_phrases_found'] = $hallucinations;
// Grounding coherence (does response use data that's in context)
$key_facts = ['48', 'pharma', 'ethica', 'vistex', 'score'];
$facts_used = 0;
foreach ($key_facts as $f) {
if (strpos($lower_resp, $f) !== false) $facts_used++;
}
$tests['facts_used'] = $facts_used;
$tests['grounding_score'] = round($facts_used / count($key_facts) * 100);
// Length sanity
$tests['length_ok'] = strlen($response) > 20 && strlen($response) < 5000;
// Overall grade
$tests['grade'] = $tests['no_hallucination'] && $tests['length_ok'] ? 'A' : 'B';
return [
'phase' => 'tests',
'passed' => $tests['no_hallucination'] && $tests['length_ok'],
'tests' => $tests,
];
}
// ═══════════════════════════════════════════════════════════════════
// EXECUTION (7-phase pattern CLAUDE)
// ═══════════════════════════════════════════════════════════════════
$result = ['wave' => 254, 'session' => $session, 'message' => $message, 'phases' => []];
// Phase 1: Thinking
$p1_start = microtime(true);
$result['phases']['thinking'] = phase_thinking($message);
$result['phases']['thinking']['duration_ms'] = round((microtime(true) - $p1_start) * 1000);
// Phase 2: Plan
$p2_start = microtime(true);
$result['phases']['plan'] = phase_plan($result['phases']['thinking']['intents_detected']);
$result['phases']['plan']['duration_ms'] = round((microtime(true) - $p2_start) * 1000);
// Phase 3: Dispatch (PARALLEL)
$result['phases']['dispatch'] = phase_dispatch($result['phases']['plan']['agent_details']);
// Phase 4: Ground
$p4_start = microtime(true);
$result['phases']['ground'] = phase_ground($result['phases']['dispatch']);
$result['phases']['ground']['duration_ms'] = round((microtime(true) - $p4_start) * 1000);
// Phase 5: Synthesize
$result['phases']['synthesize'] = phase_synthesize($message, $result['phases']['ground']['context'], $result['phases']['thinking']['intents_detected']);
// Phase 6: Tests
$p6_start = microtime(true);
$response_text = $result['phases']['synthesize']['response'] ?? '';
$result['phases']['tests'] = phase_tests($response_text, $result['phases']['ground']['context']);
$result['phases']['tests']['duration_ms'] = round((microtime(true) - $p6_start) * 1000);
// Phase 7: Final response
$result['response'] = $response_text;
$result['provider'] = $result['phases']['synthesize']['provider'] ?? '?';
$result['agents_used'] = $result['phases']['plan']['agents_to_call'] ?? [];
$result['agents_succeeded'] = $result['phases']['dispatch']['agents_succeeded'] ?? 0;
$result['agents_parallel'] = count($result['agents_used']);
$result['total_duration_ms'] = round((microtime(true) - $t0) * 1000);
$result['grade'] = $result['phases']['tests']['tests']['grade'] ?? '?';
$result['grounding_score'] = $result['phases']['tests']['tests']['grounding_score'] ?? 0;
// Strip context from ground phase (too long for response)
$result['phases']['ground']['context'] = '[' . strlen($result['phases']['ground']['context']) . ' chars, not included]';
// Strip raw dispatch data (keep only summary)
foreach ($result['phases']['dispatch']['results'] as $k => &$v) {
unset($v['data']);
unset($v['raw_size']);
}
unset($v);
echo json_encode($result, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);