diff --git a/application/controllers/KnowledgeBaseController.php b/application/controllers/KnowledgeBaseController.php
new file mode 100644
index 0000000..aed1817
--- /dev/null
+++ b/application/controllers/KnowledgeBaseController.php
@@ -0,0 +1,329 @@
+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)
+}
diff --git a/application/services/AIService.php b/application/services/AIService.php
index 1008122..7138034 100644
--- a/application/services/AIService.php
+++ b/application/services/AIService.php
@@ -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,
+ ];
+ }
}
diff --git a/application/services/EmbedService.php b/application/services/EmbedService.php
new file mode 100644
index 0000000..3215c52
--- /dev/null
+++ b/application/services/EmbedService.php
@@ -0,0 +1,66 @@
+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;
+ }
+}
diff --git a/application/services/RagService.php b/application/services/RagService.php
new file mode 100644
index 0000000..6a26d77
--- /dev/null
+++ b/application/services/RagService.php
@@ -0,0 +1,64 @@
+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);
+ }
+}
diff --git a/application/services/VectorService.php b/application/services/VectorService.php
new file mode 100644
index 0000000..dbd093e
--- /dev/null
+++ b/application/services/VectorService.php
@@ -0,0 +1,158 @@
+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) ?? []) : [],
+ ];
+ }
+}
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 51a2d3c..7297187 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -13,6 +13,7 @@ services:
- ../application:/var/www/nis2-agile/application
- ../public:/var/www/nis2-agile/public
- nis2-uploads:/var/www/nis2-agile/public/uploads
+ - /opt/devenv/scripts/vault-entrypoint.sh:/usr/local/bin/vault-entrypoint.sh:ro
env_file:
- ../.env
environment:
@@ -25,8 +26,18 @@ services:
- DB_PASS=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
- 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:
- nis2-network
+ - vault-net
depends_on:
db:
condition: service_healthy
@@ -76,14 +87,30 @@ services:
networks:
- 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:
nis2-db-data:
driver: local
nis2-uploads:
driver: local
+ nis2-qdrant-data:
+ driver: local
# ── Networks ─────────────────────────────────────────────────────────────
networks:
nis2-network:
driver: bridge
+ vault-net:
+ external: true
+ name: vault-net
diff --git a/docs/sql/012_consulting_firms.sql b/docs/sql/012_consulting_firms.sql
new file mode 100644
index 0000000..ffc874e
--- /dev/null
+++ b/docs/sql/012_consulting_firms.sql
@@ -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;
diff --git a/docs/sql/013_firm_assignments.sql b/docs/sql/013_firm_assignments.sql
new file mode 100644
index 0000000..2304097
--- /dev/null
+++ b/docs/sql/013_firm_assignments.sql
@@ -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;
diff --git a/public/js/kb.js b/public/js/kb.js
new file mode 100644
index 0000000..003918b
--- /dev/null
+++ b/public/js/kb.js
@@ -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 '' + escHtml(scope) + ' ';
+ }
+
+ 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 = 'Nessuna organizzazione nello studio ';
+ return;
+ }
+ shareSel.innerHTML = orgs.map(function (o) {
+ var label = o.name + (o.vat_number ? ' (P.IVA ' + o.vat_number + ')' : '');
+ return '' + escHtml(label) + ' ';
+ }).join('');
+ }).catch(function () {
+ shareSel.innerHTML = 'Errore caricamento organizzazioni ';
+ });
+ }
+
+ function loadDocList() {
+ if (!listEl) return;
+ listEl.innerHTML = '
Caricamento...
';
+ 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 = 'Nessun documento visibile
';
+ return;
+ }
+ listEl.innerHTML = docs.map(function (d) {
+ return '' +
+ '
' +
+ '
' + escHtml(d.title) + ' ' + fmtScopeBadge(d.scope) +
+ '
' +
+ escHtml(d.entity_type || '') + ' · ' + (d.chunk_count || 0) + ' chunk · ' + escHtml(d.created_at) +
+ '
' +
+ '
' +
+ '
Elimina ' +
+ '
';
+ }).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 = 'Errore caricamento
';
+ });
+ }
+
+ 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 = 'Ricerca in corso...
';
+ 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 = 'Nessun risultato
';
+ return;
+ }
+ resultsEl.innerHTML = hits.map(function (h, i) {
+ return '' +
+ '
[' + (i + 1) + '] ' + escHtml(h.title) + ' ' +
+ fmtScopeBadge(h.scope) + ' score=' + h.score + '
' +
+ '
' +
+ escHtml(h.content.substring(0, 300)) + (h.content.length > 300 ? '...' : '') +
+ '
';
+ }).join('');
+ });
+ });
+
+ // Carica lista al boot
+ loadDocList();
+})();
diff --git a/public/kb.html b/public/kb.html
new file mode 100644
index 0000000..2d947cf
--- /dev/null
+++ b/public/kb.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+ Knowledge Base - NIS2 Agile
+
+
+
+
+
+
+
+
+
+
+
+
+ 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:
+ SYSTEM (vendor), FIRM (studio + condivisioni esplicite),
+ ORG (singola organizzazione cliente).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+