[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:
parent
94d7867cea
commit
d5d83bb3b9
75
application/controllers/AiController.php
Normal file
75
application/controllers/AiController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +34,31 @@ Scoperto che **"Agile" = `consulting_firms` id 1 → "Agile Technology SRL"** (u
|
||||
- ❌ **La chiave SSH documentata `docs/credentials/hetzner_key` è ASSENTE.**
|
||||
- ✅ Usata la **chiave SSH effimera `.ssh-temp/id_ed25519_nis2-agile_8h_20260529-120834`** (validità 8h da 12:08 → scade ~20:08 del 29/05) per `ssh root@135.181.149.254` → `mysql -h localhost -u nis2_user`. Creds DB da `/var/www/nis2-agile/.env` sul server.
|
||||
|
||||
### 3. Integrazione analisi `docs/nis2/` → prodotto (v1.7.0) — DEPLOYATA in produzione
|
||||
L'utente ha caricato in `docs/nis2/` mockup HTML di un sistema NIS2 + 5 PDF normativi ufficiali. Integrati nel prodotto e **deployati live**. Doc completo: **`docs/nis2/INTEGRAZIONE_COMPLETATA.md`**.
|
||||
- **Fase 1** Asset Relevance Scoring NIS2 0-100 a 6 criteri (GV.OC-04): `AssetScoringService.php`, endpoint `assets/scoringGrid|{id}/score|relevantSystems`, UI `assets.html`, registro stampabile (`audit/relevantSystemsRegister`). SQL `020`.
|
||||
- **Fase 2** Tassonomia incidenti Determina ACN 164179/2025: IS-1..4 + `entity_obligation` essential/important (Allegati 3/4). SQL `021`, `IncidentController::create`, `AIService::classifyIncident`.
|
||||
- **Fase 3** PIR 5-Whys + metriche TTD/TTC/TTR + timestamp di fase auto. SQL `022` (tab `incident_pir`), endpoint `incidents/{id}/metrics|pir`.
|
||||
- **Fase 4** Mapping NIST CSF 2.0 (43 controlli) reference-only: `audit/nistCsfMapping` (no migrazione).
|
||||
- **FONTI CERTE** (richiesta utente: AI+help citano fonti certe): `application/config/nis2_sources.php` (registry), `AIService::authoritativeSourcesBlock()` iniettato nei prompt (vieta riferimenti inventati), citazioni in `help.js`, **ingest PDF normativi nella KB**: `scripts/ingest-nis2-sources.php` → **287 chunk in Qdrant `nis2_kb` scope SYSTEM** (retrieval verificato).
|
||||
- **Analisi concorrenza Evix**: `docs/EVIX_ANALISI_CONCORRENZA.html` (gap-driven, suite=prodotti Agile, vs GRC intl + NIS2 IT).
|
||||
|
||||
**Eseguito su Hetzner**: backup `/root/backup_pre_v170_20260529_165447.sql`; migrazioni 020/021/022 applicate; ingest 287 chunk; smoke test endpoint (401 ok).
|
||||
|
||||
### ⚙️ Scoperte infrastrutturali NON OVVIE (per prossime sessioni)
|
||||
- 🔑 **`/projects/nis2-agile` (devenv) e `/var/www/nis2-agile` (host) sono LO STESSO filesystem via bind mount** → le modifiche ai file sono **già live in produzione**, scp/git-pull NON servono. Confermato: stesso `git status`.
|
||||
- 🐛 **Qdrant IP drift**: `nis2-qdrant` non ha IP statico in compose, era driftato `172.21.0.5`→`172.21.0.3`. L'IP era hardcoded in `VectorService.php` E in `docker-compose.yml` (`QDRANT_URL`). **Fix applicato**: entrambi → hostname `http://nis2-qdrant:6333` (drift-proof). `app` ricreato (`docker compose up -d app`), vault/chiavi preservate, RAG web verificata.
|
||||
- ⚠️ **`docker.conf` ha `clear_env = no`** → php-fpm **EREDITA** le env (contraddice la vecchia nota CLAUDE.md su clear_env): per questo l'IP morto in `QDRANT_URL` rompeva la RAG web. CLI risolve l'hostname Qdrant via Docker DNS.
|
||||
- ℹ️ `pdftotext` presente sull'host ma NON nel container `nis2-app` (alpine); Qdrant raggiungibile solo dal container (network), non dall'host. Strategia ingest: estrarre testo su host in cache `.txt`, far girare l'ingest nel container (legge `.txt`).
|
||||
- ✅ **ARIA cablato**: creato `AiController::ask` (`POST /api/ai/ask`) → `AIService::askWithRag`. Il FAB ARIA (`common.js`) chiamava `/api/ai/ask` ma il controller NON esisteva → assistente AI **era rotto**, ora funziona e cita le fonti certe (verificato in prod: `rag_used=True`, sources = Ambiti/Determina). `kb_uploaded_documents` non esiste su questo DB (mig. KB 012-014 non applicate qui) → tracking MySQL KB saltato (best-effort), ma la ricerca legge da Qdrant.
|
||||
- 🐛🔑 **DNS php-fpm (verificato con diagnostico fpm-fcgi reale, poi rimosso)**: nei worker php-fpm Alpine/musl, durante una request HTTP, **`getenv('QDRANT_URL')` ritorna false** E **`gethostbyname`/curl NON risolvono gli hostname Docker single-label** (`nis2-qdrant` → "Could not resolve host"). **Funziona SOLO un IP letterale** (172.21.0.3 → 200). In CLI invece getenv+gethostbyname funzionano. Perciò: `VectorService` fallback = **IP letterale `172.21.0.3`** (fpm-safe, live via bind mount); `QDRANT_URL` compose = hostname (per CLI/cron, drift-proof via gethostbyname). ⚠️ **DRIFT**: se nis2-qdrant viene ricreato e cambia IP, aggiornare il fallback in `VectorService.php`. **Fix definitivo** (non fatto, richiede recreate rete): `ipv4_address` statico per qdrant in `docker-compose.yml` (subnet 172.21.0.0/16; IP liberi es. .10). NB: `clear_env=no` in docker.conf NON basta a passare l'env ai worker (verificato getenv=false).
|
||||
- ℹ️ Esiste un `public/_dbg_voyage.php` (debug Voyage, non mio) ancora presente in prod — valutare rimozione.
|
||||
|
||||
### ⚠️ TODO aperti (sessione 3)
|
||||
- **PUSH Gitea**: 2 commit miei (`5c545ea` feat + `94d7867` fix Qdrant) + i 5 dell'agente sono SOLO locali. Serve `git-login` (token) + `git push origin main`. Tentato, fallito per token assente.
|
||||
- **Lavoro "prossimo" da AgileHub**: l'utente l'ha menzionato ma **senza allegare contenuto/ID ticket** → da chiarire alla ripresa.
|
||||
- Opzionale: cablare `askWithRag` a endpoint web; valutare IP statico Qdrant in compose; applicare migrazioni KB `kb_uploaded_documents` se serve la lista doc in UI.
|
||||
|
||||
---
|
||||
|
||||
## SESSIONE MATTINA — 2026-05-29 (TRPG alignment)
|
||||
|
||||
@ -108,6 +108,7 @@ $controllerMap = [
|
||||
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
|
||||
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
|
||||
'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014)
|
||||
'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe)
|
||||
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
|
||||
];
|
||||
|
||||
@ -433,6 +434,10 @@ $actionMap = [
|
||||
'DELETE:{id}' => 'delete',
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
'POST:ask' => 'ask',
|
||||
],
|
||||
|
||||
// ── BrandingController — White-label firm (Fase 5 / G16) ──
|
||||
'branding' => [
|
||||
'GET:current' => 'getCurrent',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user