[FEAT] AiController /api/ai/ask (ARIA) -> askWithRag + fix DNS Qdrant php-fpm

Il FAB ARIA (common.js) chiamava POST /api/ai/ask ma il controller non esisteva
(assistente AI rotto). Creato AiController::ask -> AIService::askWithRag con RAG su KB
+ grounding fonti certe. Verificato in produzione: rag_used=True, cita Ambiti NIS2 / Determina ACN.

Fix DNS Qdrant: nei worker php-fpm (musl) getenv e gethostbyname NON funzionano per
hostname Docker single-label; funziona solo un IP letterale. VectorService fallback ->
172.21.0.3 (fpm-safe); QDRANT_URL compose resta hostname per CLI. Vedi nota drift in VectorService.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-29 18:55:44 +02:00
co-authored by Claude Opus 4.8
parent 94d7867cea
commit d5d83bb3b9
4 changed files with 130 additions and 2 deletions
+75
View File
@@ -0,0 +1,75 @@
<?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() ?? [];
$userContext = [
'user_id' => $userId,
'organization_id' => $this->getCurrentOrgId(), // può essere null
'consulting_firm_id' => $user['consulting_firm_id'] ?? null,
];
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');
}
}
}
+25 -2
View File
@@ -16,11 +16,34 @@ class VectorService
// PHP-FPM Alpine non popola correttamente env via getenv() (clear_env non
// applicato + bug DNS musl per hostname senza dots). Workaround: leggi da
// multiple sources e ricadi su IP statico del container Qdrant.
// Risoluzione Qdrant URL. Bug noto (verificato 2026-05-29): nei worker PHP-FPM
// (Alpine/musl) durante una request HTTP NON funziona né getenv('QDRANT_URL')
// (ritorna false) né la risoluzione DNS dell'hostname (gethostbyname e curl
// falliscono per i nomi single-label Docker). Funziona SOLO un IP letterale.
// In CLI invece getenv + gethostbyname funzionano. Strategia:
// - env (CLI): usa QDRANT_URL (hostname) e, sotto, gethostbyname -> IP (drift-proof in CLI)
// - fpm: env=false -> fallback IP LETTERALE qui sotto (unico modo che funziona in fpm)
// NOTA DRIFT: se il container nis2-qdrant viene ricreato e cambia IP, aggiornare
// questo fallback. Fix definitivo: assegnare ipv4_address statico a qdrant in
// docker-compose.yml (richiede recreate della rete) e allineare qui l'IP.
$url = getenv('QDRANT_URL')
?: ($_SERVER['QDRANT_URL'] ?? null)
?: ($_ENV['QDRANT_URL'] ?? null)
?: 'http://nis2-qdrant:6333'; // hostname Qdrant (agg. 2026-05-29): drift-proof. L'IP hardcoded .5 era driftato a .3; con clear_env=no php-fpm eredita QDRANT_URL e risolve l'hostname via Docker DNS (CLI verificato 200). Evita ricorrenze del drift IP.
$this->qdrantUrl = rtrim($url, '/');
?: 'http://172.21.0.3:6333'; // IP letterale nis2-qdrant (fpm-safe). Vedi nota drift sopra.
// Se l'URL contiene un hostname (caso CLI/env), prova a risolverlo a IP per
// evitare il problema di risoluzione dentro curl. In fpm gethostbyname fallisce
// e l'URL resta invariato: per questo il fallback sopra è già un IP.
$url = rtrim($url, '/');
if (preg_match('#^(https?://)([^/:]+)(:\d+)?(.*)$#', $url, $m)) {
$host = $m[2];
if (!filter_var($host, FILTER_VALIDATE_IP)) {
$ip = gethostbyname($host);
if ($ip && $ip !== $host && filter_var($ip, FILTER_VALIDATE_IP)) {
$url = $m[1] . $ip . ($m[3] ?? '') . ($m[4] ?? '');
}
}
}
$this->qdrantUrl = $url;
$this->collection = $collection;
}