[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:
parent
9b53ca3ba1
commit
a7a21faa82
329
application/controllers/KnowledgeBaseController.php
Normal file
329
application/controllers/KnowledgeBaseController.php
Normal 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)
|
||||
}
|
||||
@ -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
application/services/EmbedService.php
Normal file
66
application/services/EmbedService.php
Normal 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
application/services/RagService.php
Normal file
64
application/services/RagService.php
Normal 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
application/services/VectorService.php
Normal file
158
application/services/VectorService.php
Normal 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) ?? []) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
61
docs/sql/012_consulting_firms.sql
Normal file
61
docs/sql/012_consulting_firms.sql
Normal 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;
|
||||
41
docs/sql/013_firm_assignments.sql
Normal file
41
docs/sql/013_firm_assignments.sql
Normal 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
230
public/js/kb.js
Normal 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 || '') + ' · ' + (d.chunk_count || 0) + ' chunk · ' + 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
134
public/kb.html
Normal 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–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>
|
||||
Loading…
Reference in New Issue
Block a user