RAG su Knowledge Base (incl. fonti normative * certe ingerite, scope SYSTEM) + grounding con citazioni. */ require_once __DIR__ . '/BaseController.php'; require_once __DIR__ . '/../services/AIService.php'; require_once __DIR__ . '/../services/RateLimitService.php'; class AiController extends BaseController { /** * POST /api/ai/ask * Body: { question: string, history?: array } * Risposta: { answer, sources, rag_used } */ public function ask(): void { $this->requireAuth(); $question = trim((string) $this->getParam('question', '')); if ($question === '') { $this->jsonError('Domanda mancante', 422, 'QUESTION_REQUIRED'); } if (mb_strlen($question) > 2000) { $this->jsonError('Domanda troppo lunga (max 2000 caratteri)', 422, 'QUESTION_TOO_LONG'); } // Rate limit per utente (riusa la soglia AI configurata) $userId = $this->getCurrentUserId(); $rlKey = "ai_ask:{$userId}"; if (defined('RATE_LIMIT_AI')) { RateLimitService::check($rlKey, RATE_LIMIT_AI); RateLimitService::increment($rlKey); } $user = $this->getCurrentUser() ?? []; // Pagina corrente (facoltativa) inviata dal frontend, per dare ad ARIA // contesto sulla schermata da cui l'utente sta scrivendo (ticket #424). $page = mb_substr(trim((string) $this->getParam('page', '')), 0, 120); // pageId canonico + testo dell'help "?" della pagina (allineamento ARIA↔Help). $pageId = mb_substr(trim((string) $this->getParam('page_id', '')), 0, 40); $pageHelp = mb_substr(trim((string) $this->getParam('page_help', '')), 0, 2500); // Org attiva per lo snapshot dati di ARIA. ask() chiama requireAuth() ma NON // requireOrgAccess(), quindi getCurrentOrgId() resta null e org_data_ok sarebbe // sempre falso (ARIA cieca sui dati org). Risolviamo a mano: header // X-Organization-Id → param org_id → currentOrgId → org primaria dell'utente. // Lo spoofing è neutralizzato dal controllo membership qui sotto. $orgId = (int) ($_SERVER['HTTP_X_ORGANIZATION_ID'] ?? $this->getParam('org_id') ?? 0); if ($orgId <= 0) $orgId = (int) ($this->getCurrentOrgId() ?? 0); if ($orgId <= 0) { $primary = Database::fetchOne( 'SELECT organization_id FROM user_organizations WHERE user_id = ? ORDER BY is_primary DESC, id ASC LIMIT 1', [$userId] ); $orgId = (int) ($primary['organization_id'] ?? 0); } $userContext = [ 'user_id' => $userId, 'organization_id' => $orgId ?: null, 'consulting_firm_id' => $user['consulting_firm_id'] ?? null, 'page' => $page, 'page_id' => $pageId, 'page_help' => $pageHelp, ]; // Membership verificata → ARIA può iniettare lo snapshot DATI dell'org // (anti-spoof X-Organization-Id; super_admin bypassa, come da modello ruoli). $orgId = (int) ($userContext['organization_id'] ?? 0); $userContext['org_data_ok'] = false; if ($orgId > 0) { if (($user['role'] ?? '') === 'super_admin') { $userContext['org_data_ok'] = true; } else { $member = Database::fetchOne( 'SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ?', [$userId, $orgId] ); $userContext['org_data_ok'] = (bool) $member; } } try { $aiService = new AIService(); $result = $aiService->askWithRag($question, $userContext); // Audit best-effort (non blocca la risposta) try { $aiService->logInteraction( (int) ($userContext['organization_id'] ?? 0), (int) ($userId ?? 0), 'qa', mb_substr($question, 0, 200), mb_substr((string) ($result['answer'] ?? ''), 0, 500), ); } catch (Throwable $e) { error_log('[AiController::ask] logInteraction failed: ' . $e->getMessage()); } $this->jsonSuccess([ 'answer' => $result['answer'] ?? '', 'sources' => $result['sources'] ?? [], 'rag_used' => $result['rag_used'] ?? false, ]); } catch (Throwable $e) { error_log('[AiController::ask] ' . $e->getMessage()); $this->jsonError('Assistente AI non disponibile in questo momento', 503, 'AI_UNAVAILABLE'); } } /** * POST /api/ai/tts — Voce naturale di ARIA. * Proxy same-origin verso nexus-voice-ms (ElevenLabs): il voice-ms non espone CORS * per l'origin NIS2 → il browser non può chiamarlo diretto. Cache MP3 per hash del * testo (stesse frasi → niente costo ripetuto). Stream audio/mpeg. */ public function tts(): void { $this->requireAuth(); $text = trim((string) $this->getParam('text', '')); if ($text === '') { $this->jsonError('Testo mancante', 422, 'TTS_EMPTY'); } if (mb_strlen($text) > 800) { $text = mb_substr($text, 0, 800); } $lang = substr(strtolower(preg_replace('/[^a-z]/', '', (string) $this->getParam('lang', 'it')) ?: 'it'), 0, 5) ?: 'it'; $voice = (string) $this->getParam('voice_id', defined('TTS_VOICE_ID') ? TTS_VOICE_ID : 'EXAVITQu4vr4xnSDxMaL'); if (!preg_match('/^[A-Za-z0-9]{8,40}$/', $voice)) { $voice = defined('TTS_VOICE_ID') ? TTS_VOICE_ID : 'EXAVITQu4vr4xnSDxMaL'; } $cacheDir = sys_get_temp_dir() . '/nis2-tts-cache'; $cacheFile = $cacheDir . '/' . hash('sha256', $voice . '|' . $lang . '|' . $text) . '.mp3'; if (is_file($cacheFile) && filesize($cacheFile) > 256) { $this->streamMp3((string) file_get_contents($cacheFile), 'HIT'); return; } $payload = json_encode([ 'text' => $text, 'lang' => $lang, 'voice_id' => $voice, 'model' => 'eleven_turbo_v2_5', 'stability' => 0.5, 'similarity_boost' => 0.75, 'style' => 0.0, 'output_format' => 'mp3_44100_128', ]); $url = defined('VOICE_MS_URL') ? VOICE_MS_URL : 'http://172.21.0.1:4215/tts/speak'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 6, CURLOPT_TIMEOUT => 30, ]); $audio = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); curl_close($ch); if ($code !== 200 || !$audio || strlen($audio) < 256 || stripos($ctype, 'audio') === false) { $this->jsonError('Sintesi vocale non disponibile', 502, 'TTS_UPSTREAM'); } if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } @file_put_contents($cacheFile . '.tmp', $audio); @rename($cacheFile . '.tmp', $cacheFile); $this->streamMp3($audio, 'MISS'); } private function streamMp3(string $audio, string $cache): void { header('Content-Type: audio/mpeg'); header('Content-Length: ' . strlen($audio)); header('Cache-Control: private, max-age=86400'); header('X-TTS-Cache: ' . $cache); echo $audio; exit; } }