[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
parent 9b53ca3ba1
commit a7a21faa82
10 changed files with 1164 additions and 0 deletions

View File

@ -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)
}

View File

@ -564,4 +564,58 @@ PROMPT;
'model_used' => $this->model, '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,
];
}
} }

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;
}
}

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);
}
}

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) ?? []) : [],
];
}
}

View File

@ -13,6 +13,7 @@ services:
- ../application:/var/www/nis2-agile/application - ../application:/var/www/nis2-agile/application
- ../public:/var/www/nis2-agile/public - ../public:/var/www/nis2-agile/public
- nis2-uploads:/var/www/nis2-agile/public/uploads - nis2-uploads:/var/www/nis2-agile/public/uploads
- /opt/devenv/scripts/vault-entrypoint.sh:/usr/local/bin/vault-entrypoint.sh:ro
env_file: env_file:
- ../.env - ../.env
environment: environment:
@ -25,8 +26,18 @@ services:
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- VOYAGE_API_KEY=${VOYAGE_API_KEY}
- VOYAGE_MODEL=${VOYAGE_MODEL:-voyage-3-lite}
- QDRANT_URL=http://172.21.0.5:6333
- VAULT_STEWARD_URL=https://vault-steward:8443
- VAULT_APP_TOKEN=${VAULT_APP_TOKEN_NIS2}
- VAULT_PREFIX=tier1__nis2-app__
- VAULT_REQUIRED=true
entrypoint: ["/usr/local/bin/vault-entrypoint.sh"]
command: ["docker-php-entrypoint", "php-fpm"]
networks: networks:
- nis2-network - nis2-network
- vault-net
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -76,14 +87,30 @@ services:
networks: networks:
- nis2-network - nis2-network
# -- Qdrant Vector DB (Migration 012-014: KB multi-livello) --
qdrant:
image: qdrant/qdrant:v1.7.4
container_name: nis2-qdrant
restart: unless-stopped
mem_limit: 512m
volumes:
- nis2-qdrant-data:/qdrant/storage
networks:
- nis2-network
# ── Volumes ────────────────────────────────────────────────────────────── # ── Volumes ──────────────────────────────────────────────────────────────
volumes: volumes:
nis2-db-data: nis2-db-data:
driver: local driver: local
nis2-uploads: nis2-uploads:
driver: local driver: local
nis2-qdrant-data:
driver: local
# ── Networks ───────────────────────────────────────────────────────────── # ── Networks ─────────────────────────────────────────────────────────────
networks: networks:
nis2-network: nis2-network:
driver: bridge driver: bridge
vault-net:
external: true
name: vault-net

View File

@ -0,0 +1,61 @@
-- NIS2 Migration 012: Consulting Firms (Studio di Consulenza)
-- Database: nis2_agile_db
-- Data: 2026-04-11
-- Idempotente: rieseguibile in sicurezza.
USE nis2_agile_db;
-- 1. Anagrafica studi di consulenza cybersecurity / NIS2 (idempotente)
CREATE TABLE IF NOT EXISTS consulting_firms (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL COMMENT 'Ragione sociale studio',
vat_number VARCHAR(20) NULL,
fiscal_code VARCHAR(16) NULL,
forma_giuridica VARCHAR(50) NULL,
address VARCHAR(255) NULL,
city VARCHAR(100) NULL,
province VARCHAR(2) NULL,
cap VARCHAR(5) NULL,
phone VARCHAR(30) NULL,
pec VARCHAR(255) NULL,
website VARCHAR(255) NULL,
plan ENUM('starter','professional','enterprise') NOT NULL DEFAULT 'professional',
max_organizations INT NOT NULL DEFAULT 50,
max_users INT NOT NULL DEFAULT 5,
status ENUM('active','trial','suspended','inactive') NOT NULL DEFAULT 'active',
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_vat (vat_number),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. ALTER users.consulting_firm_id (idempotente)
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'consulting_firm_id');
SET @sql := IF(@col = 0,
'ALTER TABLE users ADD COLUMN consulting_firm_id INT NULL AFTER role',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_user_firm');
SET @sql := IF(@idx = 0,
'ALTER TABLE users ADD INDEX idx_user_firm (consulting_firm_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. ALTER organizations.consulting_firm_id (idempotente)
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'organizations' AND COLUMN_NAME = 'consulting_firm_id');
SET @sql := IF(@col = 0,
'ALTER TABLE organizations ADD COLUMN consulting_firm_id INT NULL',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'organizations' AND INDEX_NAME = 'idx_org_firm');
SET @sql := IF(@idx = 0,
'ALTER TABLE organizations ADD INDEX idx_org_firm (consulting_firm_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,41 @@
-- NIS2 Migration 013: Firm-Organization assignments + KB tracking
-- Database: nis2_agile_db
-- Data: 2026-04-11
USE nis2_agile_db;
-- 1. Mapping firm -> organization (M:N)
CREATE TABLE IF NOT EXISTS firm_org_assignments (
id INT AUTO_INCREMENT PRIMARY KEY,
consulting_firm_id INT NOT NULL,
organization_id INT NOT NULL,
assigned_to INT NOT NULL DEFAULT 0
COMMENT '0=tutti i membri del firm, user_id=membro specifico',
assigned_by INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_firm_org_user (consulting_firm_id, organization_id, assigned_to),
INDEX idx_firm (consulting_firm_id),
INDEX idx_org (organization_id),
INDEX idx_assigned (assigned_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. Registro documenti uploadati nella KB (audit + listing)
CREATE TABLE IF NOT EXISTS kb_uploaded_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
qdrant_doc_uuid VARCHAR(64) NOT NULL COMMENT 'Identifica il gruppo di chunk in Qdrant (per delete-by-filter)',
scope ENUM('SYSTEM','FIRM','ORG') NOT NULL DEFAULT 'SYSTEM',
consulting_firm_id INT NULL,
organization_id INT NULL,
uploaded_by INT NOT NULL,
title VARCHAR(255) NOT NULL,
entity_type VARCHAR(64) NULL,
source VARCHAR(255) NULL,
lang VARCHAR(5) NOT NULL DEFAULT 'it',
chunk_count INT NOT NULL DEFAULT 0,
shared_with_orgs JSON NULL COMMENT 'Array di organization_id quando scope=FIRM',
status ENUM('processing','ready','failed') NOT NULL DEFAULT 'ready',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_scope_firm (scope, consulting_firm_id),
INDEX idx_scope_org (scope, organization_id),
INDEX idx_uuid (qdrant_doc_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

230
public/js/kb.js Normal file
View File

@ -0,0 +1,230 @@
/**
* NIS2 Agile - Knowledge Base UI (Migration 012-014)
*
* Gestisce upload documento, listing, search semantica con visibilita' multi-livello.
*/
(function () {
'use strict';
function getJwt() {
try {
return localStorage.getItem('access_token') || sessionStorage.getItem('access_token') || '';
} catch (e) { return ''; }
}
function authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getJwt(),
};
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function fmtScopeBadge(scope) {
return '<span class="kb-scope-badge ' + escHtml(scope) + '">' + escHtml(scope) + '</span>';
}
var btnUpload = document.getElementById('btn-kb-upload');
var btnSubmit = document.getElementById('btn-kb-submit');
var btnCancel = document.getElementById('btn-kb-cancel');
var btnSearch = document.getElementById('btn-kb-search');
var formCard = document.getElementById('kb-form-card');
var statusEl = document.getElementById('kb-status');
var listEl = document.getElementById('kb-doc-list');
var resultsEl = document.getElementById('kb-search-results');
var shareSel = document.getElementById('kb-shared-with');
if (!btnUpload || !formCard) return;
function setupScopeForUser() {
// Decodifica JWT per estrarre role e consulting_firm_id
var jwt = getJwt();
if (!jwt) return;
try {
var payload = JSON.parse(atob(jwt.split('.')[1]));
// Il JWT NIS2 contiene solo user_id; chiamo /api/auth/me per role + firm
fetch('/api/auth/me', { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (res) {
var u = (res && res.data && (res.data.user || res.data)) || {};
var role = u.role || '';
var firmId = u.consulting_firm_id || null;
var optSystem = document.querySelector('[data-scope-opt="SYSTEM"]');
var optFirm = document.querySelector('[data-scope-opt="FIRM"]');
var optOrg = document.querySelector('[data-scope-opt="ORG"]');
if (role === 'super_admin' && optSystem) optSystem.style.display = 'inline-flex';
if (firmId && optFirm) optFirm.style.display = 'inline-flex';
if (optOrg) optOrg.style.display = 'inline-flex';
if (firmId) loadFirmOrgs();
});
} catch (e) { /* ignore */ }
}
function loadFirmOrgs() {
if (!shareSel) return;
fetch('/api/knowledgebase/firmOrgs', { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (res) {
var orgs = (res && res.data && res.data.organizations) || [];
if (!orgs.length) {
shareSel.innerHTML = '<option value="">Nessuna organizzazione nello studio</option>';
return;
}
shareSel.innerHTML = orgs.map(function (o) {
var label = o.name + (o.vat_number ? ' (P.IVA ' + o.vat_number + ')' : '');
return '<option value="' + o.id + '">' + escHtml(label) + '</option>';
}).join('');
}).catch(function () {
shareSel.innerHTML = '<option value="">Errore caricamento organizzazioni</option>';
});
}
function loadDocList() {
if (!listEl) return;
listEl.innerHTML = '<div class="text-muted">Caricamento...</div>';
fetch('/api/knowledgebase/list', { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (res) {
var docs = (res && res.data && res.data.documents) || [];
if (!docs.length) {
listEl.innerHTML = '<div class="text-muted">Nessun documento visibile</div>';
return;
}
listEl.innerHTML = docs.map(function (d) {
return '<div class="kb-doc-row">' +
'<div>' +
'<strong>' + escHtml(d.title) + '</strong> ' + fmtScopeBadge(d.scope) +
'<div style="font-size:.78rem;color:var(--gray-500);">' +
escHtml(d.entity_type || '') + ' &middot; ' + (d.chunk_count || 0) + ' chunk &middot; ' + escHtml(d.created_at) +
'</div>' +
'</div>' +
'<button class="btn btn-sm btn-danger" data-del-id="' + d.id + '">Elimina</button>' +
'</div>';
}).join('');
// Wire delete buttons
listEl.querySelectorAll('[data-del-id]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!confirm('Eliminare definitivamente questo documento dalla KB?')) return;
var id = this.getAttribute('data-del-id');
fetch('/api/knowledgebase/' + id, {
method: 'DELETE',
headers: authHeaders(),
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.success) loadDocList();
else alert('Errore: ' + ((res && res.message) || 'sconosciuto'));
});
});
});
}).catch(function () {
listEl.innerHTML = '<div class="text-danger">Errore caricamento</div>';
});
}
btnUpload.addEventListener('click', function () {
var visible = formCard.style.display !== 'none';
formCard.style.display = visible ? 'none' : 'block';
if (!visible) setupScopeForUser();
});
btnCancel.addEventListener('click', function () { formCard.style.display = 'none'; });
document.querySelectorAll('input[name="kb-scope"]').forEach(function (r) {
r.addEventListener('change', function () {
var block = document.getElementById('kb-share-block');
if (block) block.style.display = (this.value === 'FIRM') ? 'block' : 'none';
});
});
btnSubmit.addEventListener('click', function () {
var title = (document.getElementById('kb-title') || {}).value || '';
var text = (document.getElementById('kb-text') || {}).value || '';
var entityType = (document.getElementById('kb-entity-type') || {}).value || 'custom';
var scopeEl = document.querySelector('input[name="kb-scope"]:checked');
var scope = scopeEl ? scopeEl.value : 'ORG';
if (!title.trim()) { statusEl.textContent = 'Inserisci un titolo'; statusEl.style.color = '#ef4444'; return; }
if (text.length < 50) { statusEl.textContent = 'Testo troppo breve (min 50 caratteri)'; statusEl.style.color = '#ef4444'; return; }
var sharedWith = [];
if (scope === 'FIRM' && shareSel) {
for (var i = 0; i < shareSel.options.length; i++) {
if (shareSel.options[i].selected && shareSel.options[i].value) {
sharedWith.push(parseInt(shareSel.options[i].value, 10));
}
}
}
btnSubmit.disabled = true;
statusEl.style.color = '#0ea5e9';
statusEl.textContent = 'Indicizzazione in corso...';
fetch('/api/knowledgebase/ingest', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
title: title.trim(),
text: text,
entity_type: entityType,
scope: scope,
shared_with_orgs: sharedWith,
}),
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
.then(function (res) {
btnSubmit.disabled = false;
if (res.ok && res.body && res.body.success !== false) {
var d = res.body.data || res.body;
statusEl.style.color = '#22C55E';
statusEl.textContent = 'Documento indicizzato (' + (d.scope || scope) + ', ' + (d.chunks || '?') + ' chunk)' +
(sharedWith.length ? ' — condiviso con ' + sharedWith.length + ' org.' : '');
document.getElementById('kb-title').value = '';
document.getElementById('kb-text').value = '';
loadDocList();
} else {
statusEl.style.color = '#ef4444';
statusEl.textContent = 'Errore: ' + ((res.body && res.body.message) || 'sconosciuto');
}
}).catch(function (e) {
btnSubmit.disabled = false;
statusEl.style.color = '#ef4444';
statusEl.textContent = 'Errore di rete: ' + (e.message || '');
});
});
btnSearch.addEventListener('click', function () {
var q = (document.getElementById('kb-search-query') || {}).value || '';
if (!q.trim()) return;
resultsEl.innerHTML = '<div class="text-muted">Ricerca in corso...</div>';
fetch('/api/knowledgebase/search', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ query: q.trim(), top_k: 5 }),
}).then(function (r) { return r.json(); })
.then(function (res) {
var hits = (res && res.data && res.data.results) || [];
if (!hits.length) {
resultsEl.innerHTML = '<div class="text-muted">Nessun risultato</div>';
return;
}
resultsEl.innerHTML = hits.map(function (h, i) {
return '<div style="padding:10px;border:1px solid var(--gray-100);border-radius:6px;margin-bottom:8px;">' +
'<div><strong>[' + (i + 1) + '] ' + escHtml(h.title) + '</strong> ' +
fmtScopeBadge(h.scope) + ' <span style="font-size:.72rem;color:var(--gray-500);">score=' + h.score + '</span></div>' +
'<div style="font-size:.84rem;color:var(--gray-700);margin-top:6px;line-height:1.4;">' +
escHtml(h.content.substring(0, 300)) + (h.content.length > 300 ? '...' : '') +
'</div></div>';
}).join('');
});
});
// Carica lista al boot
loadDocList();
})();

134
public/kb.html Normal file
View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Knowledge Base - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<style>
.kb-scope-radios { display: flex; gap: 18px; flex-wrap: wrap; font-size: .9rem; }
.kb-scope-radios label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.kb-share-block { margin-top: 12px; }
.kb-doc-list { margin-top: 24px; }
.kb-doc-row { padding: 10px 12px; border-bottom: 1px solid var(--gray-100); display: flex; justify-content: space-between; align-items: center; }
.kb-scope-badge { font-size: .72rem; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.kb-scope-badge.SYSTEM { background: #DBEAFE; color: #1E40AF; }
.kb-scope-badge.FIRM { background: #FEF3C7; color: #92400E; }
.kb-scope-badge.ORG { background: #DCFCE7; color: #166534; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Knowledge Base</h2>
<div class="content-header-actions">
<button class="btn btn-primary" id="btn-kb-upload">
<svg viewBox="0 0 20 20" fill="currentColor" style="width:18px;height:18px;vertical-align:middle;"><path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13H5.5z"/><path d="M9 13h2v5a1 1 0 11-2 0v-5z"/></svg>
Carica documento
</button>
</div>
</header>
<div class="content-body">
<p class="text-muted" style="margin-top:0;">
Carica documenti (procedure, policy, normative, training material) che l'AI usera' per
rispondere alle domande con citazioni precise. La visibilita' segue il modello a 3 livelli:
<strong>SYSTEM</strong> (vendor), <strong>FIRM</strong> (studio + condivisioni esplicite),
<strong>ORG</strong> (singola organizzazione cliente).
</p>
<!-- Form upload (hidden by default) -->
<div class="card" id="kb-form-card" style="display:none;margin-top:16px;">
<div class="card-body">
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label for="kb-title">Titolo</label>
<input type="text" id="kb-title" class="form-control" placeholder="Es: Procedura interna gestione incidenti NIS2">
</div>
<div class="form-group">
<label for="kb-entity-type">Tipo</label>
<select id="kb-entity-type" class="form-control">
<option value="custom">Procedura interna</option>
<option value="platform_guide">Linea guida</option>
<option value="csrd">Normativa</option>
<option value="training">Training</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label for="kb-text">Contenuto (testo libero, 50&ndash;50.000 caratteri)</label>
<textarea id="kb-text" rows="8" class="form-control"
placeholder="Incolla qui il testo della procedura, della checklist o del template..."></textarea>
</div>
<div style="margin-top:12px;border-top:1px solid var(--gray-100);padding-top:12px;">
<label style="font-weight:600;font-size:.85rem;">Visibilita'</label>
<div class="kb-scope-radios" style="margin-top:6px;">
<label data-scope-opt="SYSTEM" style="display:none;">
<input type="radio" name="kb-scope" value="SYSTEM"> Sistema (super_admin)
</label>
<label data-scope-opt="FIRM" style="display:none;">
<input type="radio" name="kb-scope" value="FIRM"> Studio + condivisioni
</label>
<label data-scope-opt="ORG">
<input type="radio" name="kb-scope" value="ORG" checked> Solo organizzazione corrente
</label>
</div>
</div>
<div id="kb-share-block" class="kb-share-block" style="display:none;">
<label style="font-size:.78rem;color:var(--gray-600);">
Organizzazioni clienti destinatarie (Ctrl/Cmd per selezione multipla, opzionale):
</label>
<select id="kb-shared-with" multiple size="5" class="form-control">
<option value="">Caricamento organizzazioni del firm...</option>
</select>
<div style="font-size:.72rem;color:var(--gray-500);margin-top:4px;">
Lascia vuoto per condividere solo con i collaboratori dello studio.
</div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;align-items:center;">
<button class="btn btn-primary" id="btn-kb-submit">Indicizza documento</button>
<button class="btn btn-secondary" id="btn-kb-cancel">Annulla</button>
<span id="kb-status" style="margin-left:8px;font-size:.85rem;"></span>
</div>
</div>
</div>
<!-- Lista documenti -->
<div class="card kb-doc-list">
<div class="card-header">
<h3>Documenti caricati visibili</h3>
</div>
<div class="card-body">
<div id="kb-doc-list">
<div class="text-muted">Caricamento...</div>
</div>
</div>
</div>
<!-- Search preview -->
<div class="card" style="margin-top:24px;">
<div class="card-header"><h3>Test search semantica</h3></div>
<div class="card-body">
<div style="display:flex;gap:8px;">
<input type="text" id="kb-search-query" class="form-control" placeholder="Es: come gestire un incidente NIS2?">
<button class="btn btn-secondary" id="btn-kb-search">Cerca</button>
</div>
<div id="kb-search-results" style="margin-top:12px;"></div>
</div>
</div>
</div>
</main>
</div>
<script src="js/common.js"></script>
<script src="js/api.js"></script>
<script src="js/kb.js"></script>
</body>
</html>