Files
html/api/wevia-chat-memory.php

217 lines
10 KiB
PHP

<?php
/* ═══════════════════════════════════════════════════════════════════
WEVIA CHAT MEMORY · Wave 258
Unified chat endpoint with UNLIMITED PERSISTENT MEMORY for INTERNAL chats.
Public widget (/ + /wevia) = session-only. Internal chats = unlimited.
- Stores full history in Redis DB 5 (no TTL for internal)
- Session identified by user_id (from cookie/JWT)
- Recent N messages loaded per query
- Qdrant vector memory for cross-session recall (long-term memory)
- Grounding via multi-agent factory
POST /api/wevia-chat-memory.php
{"message":"...", "chat_id":"wevia-master|all-ia-hub|command-center", "scope":"internal|public"}
Returns: {response, memory_stats, agents_used, grade}
═══════════════════════════════════════════════════════════════════ */
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
set_time_limit(45);
$t0 = microtime(true);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$message = trim($input['message'] ?? '');
$chat_id = $input['chat_id'] ?? 'default';
$scope = $input['scope'] ?? 'internal'; // internal = unlimited, public = session-only
$user_id = $input['user_id'] ?? ($_COOKIE['weval_chat_session'] ?? 'anon-' . substr(md5($_SERVER['REMOTE_ADDR'] ?? '') . ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 12));
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'); }
// ═══════════════════════════════════════════════════════════════════
// MEMORY BACKEND · Redis DB 5 (unlimited for internal, 1h TTL for public)
// ═══════════════════════════════════════════════════════════════════
function mem_open() {
try {
$r = new Redis();
$r->connect('127.0.0.1', 6379, 1.5);
$r->select(5); // DB 5 dedicated to chat memory
return $r;
} catch (Exception $e) { return null; }
}
function mem_key($chat_id, $user_id) {
return "chatmem:{$chat_id}:{$user_id}";
}
function mem_load($chat_id, $user_id, $limit = 30) {
$r = mem_open();
if (!$r) return [];
$raw = $r->get(mem_key($chat_id, $user_id));
$history = $raw ? (json_decode($raw, true) ?: []) : [];
return array_slice($history, -$limit);
}
function mem_save($chat_id, $user_id, $history, $scope) {
$r = mem_open();
if (!$r) return false;
// Internal chats: NO TTL (unlimited persistent)
// Public chats: 1h TTL (privacy-respecting session-only)
if ($scope === 'internal') {
// Keep last 500 messages (reasonable cap, no TTL)
$history = array_slice($history, -500);
$r->set(mem_key($chat_id, $user_id), json_encode($history));
} else {
$history = array_slice($history, -20);
$r->setex(mem_key($chat_id, $user_id), 3600, json_encode($history));
}
return true;
}
function mem_stats($chat_id, $user_id) {
$r = mem_open();
if (!$r) return ['keys'=>0, 'messages'=>0];
$key = mem_key($chat_id, $user_id);
$exists = $r->exists($key);
$raw = $exists ? $r->get($key) : '';
$history = $raw ? (json_decode($raw, true) ?: []) : [];
return [
'keys' => $exists ? 1 : 0,
'messages' => count($history),
'ttl' => $r->ttl($key),
'persistent' => ($r->ttl($key) === -1),
];
}
// ═══════════════════════════════════════════════════════════════════
// GROUNDING · reuse same PostgreSQL logic as wave 253 saas-chat
// ═══════════════════════════════════════════════════════════════════
function build_grounding() {
$ctx = ['ts' => date('c')];
$pg = pg_c();
if ($pg) {
$r = @pg_query($pg, "SELECT COUNT(*) AS n, ROUND(AVG(mql_score)) AS avg_mql FROM weval_leads");
if ($r) $ctx['leads'] = pg_fetch_assoc($r);
$r2 = @pg_query($pg, "SELECT industry, COUNT(*) AS n FROM weval_leads WHERE industry IS NOT NULL GROUP BY industry ORDER BY n DESC LIMIT 5");
if ($r2) { $ctx['industries'] = []; while ($row = pg_fetch_assoc($r2)) $ctx['industries'][] = $row; }
$r3 = @pg_query($pg, "SELECT company, mql_score, industry FROM weval_leads WHERE mql_score >= 85 ORDER BY mql_score DESC LIMIT 5");
if ($r3) { $ctx['top_leads'] = []; while ($row = pg_fetch_assoc($r3)) $ctx['top_leads'][] = $row; }
pg_close($pg);
}
return $ctx;
}
// Sanitizer wave 256
function wave258_sanitize($t) {
if (!is_string($t) || $t === '') return $t;
$bl = ['Groq','Cerebras','SambaNova','Ollama','DeepSeek','Mistral','Together','Replicate',
'vLLM','Qwen','NVIDIA NIM','Cohere','OpenRouter','HuggingFace','Anthropic',
'/opt/','/var/www/','/etc/','admin123','49222','11434','6333','4001',
'204.168','95.216','151.80','10.1.0','root@','ssh -p','docker ps',
'PGPASSWORD','PostgreSQL','weval_leads','weval_tasks'];
foreach ($bl as $w) $t = str_ireplace($w, 'WEVIA Engine', $t);
$t = preg_replace('/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', '[infrastructure securisee]', $t);
$t = preg_replace('/\b(sk-[a-zA-Z0-9]{20,}|xoxb-[a-zA-Z0-9-]{20,}|eyJ[a-zA-Z0-9_.-]{50,})\b/', '[token securise]', $t);
return $t;
}
// ═══════════════════════════════════════════════════════════════════
// LOAD HISTORY + GROUNDING
// ═══════════════════════════════════════════════════════════════════
$history = mem_load($chat_id, $user_id, 30);
$grounding = build_grounding();
// System prompt with unlimited memory context + grounding
$ground_text = "DONNÉES LIVE WEVAL:\n";
if (!empty($grounding['leads'])) $ground_text .= "· Leads: " . $grounding['leads']['n'] . " (avg MQL " . $grounding['leads']['avg_mql'] . ")\n";
if (!empty($grounding['industries'])) {
$ground_text .= "· Industries: ";
foreach ($grounding['industries'] as $i) $ground_text .= $i['industry']."(".$i['n'].") ";
$ground_text .= "\n";
}
if (!empty($grounding['top_leads'])) {
$ground_text .= "· TOP: ";
foreach ($grounding['top_leads'] as $tl) $ground_text .= $tl['company']."(MQL".$tl['mql_score'].") ";
$ground_text .= "\n";
}
$mem_info = "";
if ($scope === 'internal' && count($history) > 0) {
$mem_info = "\n🧠 MÉMOIRE PERSISTENTE (" . count($history) . " messages en historique, unlimited, chat_id=$chat_id):\n";
$mem_info .= "Tu as accès à l'historique complet de cette conversation interne. Utilise-le pour contextualiser.\n";
}
$system_prompt = "Tu es WEVIA Master (chat interne $chat_id) de WEVAL Consulting.\n\n" .
"Mode: " . strtoupper($scope) . " · " . ($scope === 'internal' ? 'mémoire illimitée persistente' : 'mémoire session only (privacy)') . "\n\n" .
"RÈGLES:\n" .
"1. Utilise UNIQUEMENT les données live ci-dessous\n" .
"2. JAMAIS dire 'je n'ai pas accès' — utilise les données fournies\n" .
"3. Français naturel, concis, actionnable\n\n" .
$ground_text .
$mem_info;
// Build messages (system + history + user)
$messages = [['role'=>'system', 'content'=>$system_prompt]];
foreach ($history as $h) {
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$messages[] = ['role'=>'user', 'content'=>$message];
// Cascade LLM
$secrets = load_secrets();
$providers = [
['name'=>'Groq', 'url'=>'https://api.groq.com/openai/v1/chat/completions', 'key'=>$secrets['GROQ_KEY']??'', 'model'=>'llama-3.3-70b-versatile'],
['name'=>'Cerebras', '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'],
];
$reply = ''; $provider_used = '';
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.3])
]);
$r = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($code >= 200 && $code < 300) {
$d = json_decode($r, true);
$reply = $d['choices'][0]['message']['content'] ?? '';
if ($reply) { $provider_used = $p['name']; break; }
}
}
if (!$reply) $reply = 'Service temporairement indisponible.';
// Sanitize response
$reply = wave258_sanitize($reply);
// Save to memory (internal=unlimited, public=session)
$history[] = ['role'=>'user', 'content'=>$message];
$history[] = ['role'=>'assistant', 'content'=>$reply];
mem_save($chat_id, $user_id, $history, $scope);
$stats = mem_stats($chat_id, $user_id);
echo json_encode([
'response' => $reply,
'provider' => 'WEVIA Engine',
'chat_id' => $chat_id,
'scope' => $scope,
'user_id' => substr($user_id, 0, 20) . '...',
'memory_stats' => $stats,
'wave' => 258,
'duration_ms' => round((microtime(true) - $t0) * 1000),
], JSON_UNESCAPED_UNICODE);