Files
nis2-agile/application/controllers/AiController.php
T
DevEnv nis2-agileandClaude Opus 4.8 a982797fa2 [FEAT] ARIA voce naturale (proxy TTS nexus-voice-ms) + toggle voce — Fase C
Porting voce TRPG/AllTax (ElevenLabs "Sarah") sull'ARIA chat NIS2:
- AiController::tts() + route POST /api/ai/tts: proxy same-origin a nexus-voice-ms
  (config VOICE_MS_URL=172.21.0.1:4215, voce TTS_VOICE_ID=EXAVITQu4vr4xnSDxMaL,
  eleven_turbo_v2_5). requireAuth, cache MP3 sha256 (sys_get_temp_dir/nis2-tts-cache),
  validazioni (text<=800, voice_id allowlist), stream audio/mpeg, 502 se upstream non-audio.
- common.js: speak(answer) dopo ogni risposta ARIA (voce discreta vol .55, markdown
  strip), toggle 🔊/🔇 nell'header (muto persistito localStorage nis2_aria_voice),
  autoplay best-effort.
Smoke: voice-ms da nis2-app HTTP 200 audio/mpeg 19KB; /api/ai/tts 401 senza auth.
Reachability OK (no allowlist VOX necessaria). Cache-buster common.js -> 20260624g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:58:05 +02:00

175 lines
7.1 KiB
PHP

<?php
/**
* NIS2 Agile - AI Assistant Controller (ARIA)
*
* Endpoint dell'assistente conversazionale AI usato dal FAB "ARIA" (common.js).
* Usa AIService::askWithRag() -> 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);
$userContext = [
'user_id' => $userId,
'organization_id' => $this->getCurrentOrgId(), // può essere 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;
}
}