[FEAT] Knowledge Base RAG multi-livello (SYSTEM/FIRM/ORG) + Qdrant + Voyage

- KnowledgeBaseController: ingest, list, firmOrgs, search, delete
- VectorService (Qdrant + buildAuthzFilter), EmbedService (Voyage), RagService (pipeline)
- AIService::askWithRag con fallback graceful
- docker-compose: servizio qdrant + env Voyage (chiave da .env/vault, no hardcoded)
- SQL 012 consulting_firms, 013 firm_assignments + kb_uploaded_documents
- public/kb.html + kb.js (upload, lista, search preview)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-29 15:44:13 +02:00
co-authored by Claude Opus 4.8
parent 9b53ca3ba1
commit a7a21faa82
10 changed files with 1164 additions and 0 deletions
@@ -0,0 +1,329 @@
<?php
/**
* NIS2 Agile - Knowledge Base Controller
*
* Migration 012-014: KB multi-livello (SYSTEM/FIRM/ORG) con Qdrant + Voyage.
*
* Endpoint:
* POST /api/knowledgebase/ingest - Carica nuovo documento (testo)
* GET /api/knowledgebase/list - Lista documenti caricati visibili
* GET /api/knowledgebase/firm-orgs - Lista organizzazioni del firm dell'utente (per multi-select UI)
* POST /api/knowledgebase/search - Search semantica (debug/preview)
* DELETE /api/knowledgebase/document/{id}- Cancella documento + chunk Qdrant
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/VectorService.php';
require_once APP_PATH . '/services/EmbedService.php';
require_once APP_PATH . '/services/RagService.php';
class KnowledgeBaseController extends BaseController
{
/**
* GET /api/knowledgebase/firm-orgs
* Restituisce le organizzazioni gestite dal firm dell'utente, per popolare la
* multi-select del form di upload (scope=FIRM).
*/
public function firmOrgs(): void
{
$this->requireAuth();
$firmId = $this->currentUser['consulting_firm_id'] ?? null;
if (!$firmId) {
$this->jsonSuccess(['organizations' => []]);
return;
}
$rows = Database::fetchAll(
'SELECT id, name, vat_number, sector
FROM organizations
WHERE consulting_firm_id = ? AND is_active = 1
ORDER BY name',
[(int)$firmId]
);
$this->jsonSuccess(['organizations' => $rows]);
}
/**
* POST /api/knowledgebase/ingest
* Body JSON:
* { title, text, entity_type?, source?, scope?, shared_with_orgs?, organization_id? }
*/
public function ingest(): void
{
$this->requireAuth();
$userId = (int)$this->currentUser['id'];
$userRole = $this->currentUser['role'] ?? '';
$userFirmId = $this->currentUser['consulting_firm_id'] ?? null;
// Solo questi ruoli possono uploadare. employee/auditor sono read-only.
$allowedUploadRoles = ['super_admin', 'org_admin', 'compliance_manager', 'consultant'];
if (!in_array($userRole, $allowedUploadRoles, true)) {
$this->jsonError('Ruolo non autorizzato a caricare documenti KB', 403, 'KB_FORBIDDEN');
}
$this->validateRequired(['title', 'text']);
$title = trim((string)$this->getParam('title'));
$text = (string)$this->getParam('text');
$entityType = $this->getParam('entity_type', 'custom');
$source = $this->getParam('source', $title);
$orgId = (int)$this->getParam('organization_id', 0);
$scope = strtoupper((string)$this->getParam('scope', 'SYSTEM'));
if (!in_array($scope, ['SYSTEM', 'FIRM', 'ORG'], true)) {
$scope = 'SYSTEM';
}
$sharedWith = $this->getParam('shared_with_orgs', []);
if (!is_array($sharedWith)) $sharedWith = [];
$sharedWith = array_values(array_filter(array_map('intval', $sharedWith)));
// Validazioni testo
$textLen = strlen($text);
if ($textLen < 50) {
$this->jsonError('Testo troppo breve (min 50 caratteri)', 422, 'TEXT_TOO_SHORT');
}
if ($textLen > 50000) {
$this->jsonError('Testo troppo lungo (max 50.000 caratteri)', 422, 'TEXT_TOO_LONG');
}
// Authorization per scope
if ($scope === 'SYSTEM' && !in_array($userRole, ['super_admin'], true)) {
$this->jsonError('Solo i super_admin possono caricare documenti SYSTEM', 403, 'KB_SYSTEM_FORBIDDEN');
}
if ($scope === 'FIRM') {
if (!$userFirmId) {
$this->jsonError('Solo i membri di uno studio possono caricare documenti FIRM', 403, 'KB_NO_FIRM');
}
// Verifica che le organizations di shared_with appartengano davvero al firm
if (!empty($sharedWith)) {
$placeholders = implode(',', array_fill(0, count($sharedWith), '?'));
$valid = Database::fetchAll(
"SELECT id FROM organizations WHERE id IN ($placeholders) AND consulting_firm_id = ?",
array_merge($sharedWith, [(int)$userFirmId])
);
$validIds = array_map(fn($r) => (int)$r['id'], $valid);
$invalid = array_diff($sharedWith, $validIds);
if (!empty($invalid)) {
$this->jsonError('Alcune organizzazioni non appartengono al tuo studio: ' . implode(',', $invalid), 403, 'KB_INVALID_SHARE');
}
$sharedWith = $validIds;
}
}
if ($scope === 'ORG') {
if ($orgId <= 0) {
$this->jsonError('organization_id obbligatorio per scope=ORG', 422, 'KB_ORG_REQUIRED');
}
// Verifica accesso dell'utente all'organization
if ($userRole !== 'super_admin') {
$access = Database::fetchOne(
"SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ? AND role IN ('org_admin','compliance_manager')",
[$userId, $orgId]
);
if (!$access) {
$this->jsonError('Non hai permessi di scrittura su questa organizzazione', 403, 'KB_ORG_FORBIDDEN');
}
}
}
// Chunking: ~2000 char con overlap 200
$chunks = $this->chunkText($text, 2000, 200);
try {
$embed = new EmbedService();
$vector = new VectorService();
$vector->ensureCollection($embed->dims);
$docUuid = $this->generateUuid();
$points = [];
foreach ($chunks as $i => $chunk) {
$vec = $embed->embed($chunk);
$points[] = [
'id' => $this->generateUuid(),
'vector' => $vec,
'payload' => [
'doc_uuid' => $docUuid,
'title' => $title . ($i > 0 ? ' (parte ' . ($i + 1) . ')' : ''),
'chunk' => $chunk,
'entity_type' => $entityType,
'source' => $source,
'lang' => 'it',
'scope' => $scope,
'consulting_firm_id' => $userFirmId !== null ? (int)$userFirmId : null,
'organization_id' => $orgId > 0 ? $orgId : null,
'shared_with_orgs' => $sharedWith,
'uploaded_by' => $userId,
],
];
}
$vector->upsertBatch($points);
} catch (Exception $e) {
$this->jsonError('Errore durante l\'indicizzazione: ' . $e->getMessage(), 500, 'KB_INGEST_ERROR');
}
// Tracking row in MySQL
try {
$stmt = Database::getInstance()->prepare(
"INSERT INTO kb_uploaded_documents
(qdrant_doc_uuid, scope, consulting_firm_id, organization_id, uploaded_by, title, entity_type, source, lang, chunk_count, shared_with_orgs, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')"
);
$stmt->execute([
$docUuid,
$scope,
$userFirmId,
$orgId > 0 ? $orgId : null,
$userId,
$title,
$entityType,
$source,
'it',
count($chunks),
json_encode($sharedWith),
]);
} catch (Exception $e) {
error_log('[KB] kb_uploaded_documents insert failed: ' . $e->getMessage());
}
$this->jsonSuccess([
'doc_uuid' => $docUuid,
'title' => $title,
'scope' => $scope,
'chunks' => count($chunks),
'shared_with_orgs' => $sharedWith,
], 'Documento indicizzato');
}
/**
* GET /api/knowledgebase/list
* Lista i documenti che l'utente puo' vedere via il filtro authz.
* Note: lista basata su kb_uploaded_documents (audit), non su Qdrant.
*/
public function list(): void
{
$this->requireAuth();
$userId = (int)$this->currentUser['id'];
$firmId = $this->currentUser['consulting_firm_id'] ?? null;
$orgId = $this->resolveOrgId();
$where = [];
$params = [];
// SYSTEM sempre visibile
$clauses = ["scope = 'SYSTEM'"];
if ($firmId) {
$clauses[] = "(scope = 'FIRM' AND consulting_firm_id = ?)";
$params[] = (int)$firmId;
}
if ($orgId) {
$clauses[] = "(scope = 'FIRM' AND JSON_CONTAINS(shared_with_orgs, JSON_ARRAY(?)))";
$params[] = (int)$orgId;
$clauses[] = "(scope = 'ORG' AND organization_id = ?)";
$params[] = (int)$orgId;
}
$sql = 'SELECT id, qdrant_doc_uuid, scope, consulting_firm_id, organization_id, title, entity_type, source, lang, chunk_count, shared_with_orgs, status, created_at
FROM kb_uploaded_documents
WHERE ' . implode(' OR ', $clauses) . '
ORDER BY created_at DESC LIMIT 200';
$rows = Database::fetchAll($sql, $params);
// Decode shared_with_orgs JSON
foreach ($rows as &$r) {
if (!empty($r['shared_with_orgs'])) {
$r['shared_with_orgs'] = json_decode($r['shared_with_orgs'], true) ?: [];
} else {
$r['shared_with_orgs'] = [];
}
}
$this->jsonSuccess(['documents' => $rows]);
}
/**
* POST /api/knowledgebase/search
* Body: { query, top_k? }
* Search semantica preview (utile per debug e per UI "find similar").
*/
public function search(): void
{
$this->requireAuth();
$this->validateRequired(['query']);
$query = (string)$this->getParam('query');
$topK = (int)$this->getParam('top_k', 5);
$userContext = [
'user_id' => (int)$this->currentUser['id'],
'organization_id' => $this->resolveOrgId(),
'consulting_firm_id' => $this->currentUser['consulting_firm_id'] ?? null,
];
try {
$rag = new RagService();
$hits = $rag->searchForUser($query, $userContext, $topK);
$this->jsonSuccess(['results' => $hits]);
} catch (Exception $e) {
$this->jsonError('Errore search: ' . $e->getMessage(), 500, 'KB_SEARCH_ERROR');
}
}
/**
* DELETE /api/knowledgebase/document/{id}
* Cancella documento + tutti i chunk Qdrant via doc_uuid.
*/
public function delete(int $id): void
{
$this->requireAuth();
$userRole = $this->currentUser['role'] ?? '';
$userId = (int)$this->currentUser['id'];
$doc = Database::fetchOne('SELECT * FROM kb_uploaded_documents WHERE id = ?', [$id]);
if (!$doc) {
$this->jsonError('Documento non trovato', 404, 'KB_NOT_FOUND');
}
// Solo l'uploader o un super_admin puo' cancellare
if ($userRole !== 'super_admin' && (int)$doc['uploaded_by'] !== $userId) {
$this->jsonError('Non autorizzato a cancellare questo documento', 403, 'KB_DELETE_FORBIDDEN');
}
try {
$vector = new VectorService();
$vector->deleteByFilter([
'must' => [
['key' => 'doc_uuid', 'match' => ['value' => $doc['qdrant_doc_uuid']]],
],
]);
} catch (Exception $e) {
error_log('[KB] qdrant delete failed: ' . $e->getMessage());
}
Database::query('DELETE FROM kb_uploaded_documents WHERE id = ?', [$id]);
$this->jsonSuccess(null, 'Documento eliminato');
}
// ─── helpers ─────────────────────────────────────────
private function chunkText(string $text, int $chunkSize = 2000, int $overlap = 200): array
{
$chunks = [];
$length = strlen($text);
$start = 0;
while ($start < $length) {
$end = min($start + $chunkSize, $length);
$chunks[] = substr($text, $start, $end - $start);
if ($end >= $length) break;
$start = $end - $overlap;
}
return $chunks;
}
private function generateUuid(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
// resolveOrgId() e' ereditato da BaseController (riga 351)
}
+54
View File
@@ -564,4 +564,58 @@ PROMPT;
'model_used' => $this->model,
]);
}
/**
* Migration 012-014: Q&A grounded sulla KB multi-livello.
*
* Esegue una RAG search sui documenti visibili all'utente (SYSTEM/FIRM/ORG)
* e inietta i top-K chunks nel system prompt prima di chiamare Claude.
*
* Se Voyage/Qdrant non sono disponibili, ricade su Claude diretto senza grounding.
*
* @param string $question Domanda dell'utente
* @param array $userContext ['user_id', 'organization_id', 'consulting_firm_id']
* @return array ['answer'=>string, 'sources'=>array, 'rag_used'=>bool]
*/
public function askWithRag(string $question, array $userContext): array
{
$sources = [];
$contextBlock = '';
$ragUsed = false;
// Tenta RAG: se fallisce, prosegui senza grounding (degradazione graceful)
try {
require_once __DIR__ . '/RagService.php';
$rag = new RagService();
$hits = $rag->searchForUser($question, $userContext, 5, 0.28);
if (!empty($hits)) {
$contextBlock = $rag->formatContext($hits);
$sources = array_map(fn($h) => [
'title' => $h['title'],
'scope' => $h['scope'],
'score' => $h['score'],
], $hits);
$ragUsed = true;
}
} catch (Exception $e) {
error_log('[AIService::askWithRag] RAG failed, fallback diretto: ' . $e->getMessage());
}
$systemPrompt = "Sei un esperto consulente di cybersecurity NIS2 (EU 2022/2555) e D.Lgs. 138/2024.\n"
. "Rispondi in modo preciso e cita le fonti del contesto quando rilevanti.\n";
if (!empty($contextBlock)) {
$systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $contextBlock
. "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto.";
} else {
$systemPrompt .= "\nNon e' disponibile contesto documentale specifico per questa domanda. Rispondi con la tua conoscenza generale e indica esplicitamente che non hai trovato fonti nella knowledge base.";
}
$answer = $this->callAPI($question, $systemPrompt);
return [
'answer' => $answer,
'sources' => $sources,
'rag_used' => $ragUsed,
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* NIS2 Agile - EmbedService
*
* Client minimale per Voyage AI embeddings (voyage-3-lite, 1024 dim).
*/
class EmbedService
{
public int $dims = 512;
private string $apiKey;
private string $model;
public function __construct()
{
// PHP-FPM Alpine non popola env via getenv() (clear_env). Multi-source lookup.
// La chiave vive in .env (gitignored) + vault-steward; nessun segreto hardcoded.
$this->apiKey = getenv('VOYAGE_API_KEY')
?: ($_SERVER['VOYAGE_API_KEY'] ?? '')
?: ($_ENV['VOYAGE_API_KEY'] ?? '')
?: (class_exists('Env') ? Env::get('VOYAGE_API_KEY', '') : '');
$this->model = getenv('VOYAGE_MODEL')
?: ($_SERVER['VOYAGE_MODEL'] ?? null)
?: ($_ENV['VOYAGE_MODEL'] ?? null)
?: 'voyage-3-lite';
if (empty($this->apiKey)) {
throw new RuntimeException('VOYAGE_API_KEY non configurata');
}
}
/**
* @return float[] Vettore embedding 1024-dim
*/
public function embed(string $text): array
{
$ch = curl_init('https://api.voyageai.com/v1/embeddings');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
],
CURLOPT_POSTFIELDS => json_encode([
'input' => [$text],
'model' => $this->model,
'input_type' => 'document',
'output_dimension' => 512,
]),
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200 || !$raw) {
throw new RuntimeException("Voyage embed failed (HTTP $status): " . substr((string)$raw, 0, 200));
}
$data = json_decode($raw, true);
$vec = $data['data'][0]['embedding'] ?? null;
if (!is_array($vec)) {
throw new RuntimeException('Voyage response without embedding: ' . substr($raw, 0, 200));
}
return $vec;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* NIS2 Agile - RagService
*
* Combina EmbedService + VectorService per cercare nella KB multi-livello
* filtrando per il contesto utente (Migration 012-014).
*/
require_once __DIR__ . '/VectorService.php';
require_once __DIR__ . '/EmbedService.php';
class RagService
{
private VectorService $vector;
private EmbedService $embed;
public function __construct()
{
$this->vector = new VectorService();
$this->embed = new EmbedService();
}
/**
* Cerca i top-k chunks visibili all'utente.
*
* @param array $userContext ['user_id', 'organization_id', 'consulting_firm_id']
* @return array Lista chunks con title, content, score, scope
*/
public function searchForUser(string $question, array $userContext, int $topK = 5, float $minScore = 0.28): array
{
$vector = $this->embed->embed($question);
$filter = VectorService::buildAuthzFilter($userContext);
$hits = $this->vector->search($vector, $filter, $topK, $minScore);
$out = [];
foreach ($hits as $h) {
$p = $h['payload'] ?? [];
$out[] = [
'id' => $h['id'] ?? null,
'score' => round($h['score'] ?? 0, 4),
'title' => $p['title'] ?? '',
'content' => $p['chunk'] ?? '',
'scope' => $p['scope'] ?? null,
'source' => $p['source'] ?? null,
'lang' => $p['lang'] ?? 'it',
];
}
return $out;
}
/**
* Compatta i risultati in un blocco di testo da iniettare nel system prompt Claude.
*/
public function formatContext(array $hits): string
{
if (empty($hits)) return '';
$blocks = [];
foreach ($hits as $i => $h) {
$idx = $i + 1;
$blocks[] = "[$idx] {$h['title']} (scope={$h['scope']}, score={$h['score']})\n{$h['content']}";
}
return implode("\n\n---\n\n", $blocks);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* NIS2 Agile - VectorService
*
* Client minimale Qdrant + filtro multi-livello (Migration 012-014).
* Modello a 3 livelli (SYSTEM/FIRM/ORG) coerente con TRPG e SustainAI.
*/
class VectorService
{
private string $qdrantUrl;
private string $collection;
public function __construct(string $collection = 'nis2_kb')
{
// 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.
$url = getenv('QDRANT_URL')
?: ($_SERVER['QDRANT_URL'] ?? null)
?: ($_ENV['QDRANT_URL'] ?? null)
?: 'http://172.21.0.5:6333'; // IP nis2-qdrant nella docker_nis2-network
$this->qdrantUrl = rtrim($url, '/');
$this->collection = $collection;
}
public function ensureCollection(int $dims = 1024): void
{
$info = $this->request('GET', "/collections/{$this->collection}");
if ($info['status'] === 200) return;
$this->request('PUT', "/collections/{$this->collection}", [
'vectors' => ['size' => $dims, 'distance' => 'Cosine'],
]);
}
public function upsertBatch(array $points): void
{
if (empty($points)) return;
$resp = $this->request('PUT', "/collections/{$this->collection}/points?wait=true", [
'points' => $points,
]);
if ($resp['status'] !== 200) {
throw new RuntimeException('Qdrant upsert failed (HTTP ' . $resp['status'] . '): ' . json_encode($resp['body']));
}
}
public function deleteByFilter(array $filter): void
{
$this->request('POST', "/collections/{$this->collection}/points/delete", [
'filter' => $filter,
]);
}
public function setPayloadByFilter(array $payload, array $filter): void
{
$this->request('POST', "/collections/{$this->collection}/points/payload", [
'payload' => $payload,
'filter' => $filter,
]);
}
public function search(array $vector, array $filter = [], int $limit = 8, float $minScore = 0.28): array
{
$body = [
'vector' => $vector,
'limit' => $limit,
'with_payload' => true,
'score_threshold'=> $minScore,
];
if (!empty($filter)) {
$body['filter'] = $filter;
}
$resp = $this->request('POST', "/collections/{$this->collection}/points/search", $body);
if ($resp['status'] !== 200) {
return [];
}
return $resp['body']['result'] ?? [];
}
/**
* Filtro a 3 livelli (SYSTEM/FIRM/ORG) basato sull'utente.
* Restituisce SOLO chunks visibili a quell'utente.
*
* @param array $userContext ['user_id'=>int, 'organization_id'=>int|null, 'consulting_firm_id'=>int|null]
*/
public static function buildAuthzFilter(array $userContext): array
{
$firmId = isset($userContext['consulting_firm_id']) && $userContext['consulting_firm_id'] !== null
? (int)$userContext['consulting_firm_id'] : null;
$orgId = isset($userContext['organization_id']) && $userContext['organization_id'] !== null
? (int)$userContext['organization_id'] : null;
$should = [];
// L0 SYSTEM: vendor knowledge (sempre visibile)
$should[] = ['key' => 'scope', 'match' => ['value' => 'SYSTEM']];
// L1 FIRM: KB del proprio studio (visibile a tutti i collaboratori)
if ($firmId !== null) {
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'FIRM']],
['key' => 'consulting_firm_id', 'match' => ['value' => $firmId]],
],
];
}
if ($orgId !== null) {
// L1 FIRM con sharing esplicito alla organization corrente
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'FIRM']],
['key' => 'shared_with_orgs', 'match' => ['any' => [$orgId]]],
],
];
// L2 ORG: chunk dell'organizzazione corrente
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'ORG']],
['key' => 'organization_id', 'match' => ['value' => $orgId]],
],
];
}
return ['should' => $should];
}
/**
* @return array{status:int, body:array}
*/
private function request(string $method, string $path, ?array $body = null): array
{
$url = $this->qdrantUrl . $path;
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 30,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
}
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'status' => $status,
'body' => $raw ? (json_decode($raw, true) ?? []) : [],
];
}
}