Dopo il revamp V3, allineati i layer che descrivevano ancora la sidebar V2: - topnav-v3.js: aggiunte le pagine orfane in nav (Matrice RACI → Struttura interna, Scadenziario → Monitoraggio, Compliance Journey → Guida) + cablato data-i18n su gruppi e voci del mega-menu (traduzione IT/EN come in V2). - i18n.js: nuove chiavi nav V3 (gruppi mega-menu + connettori/whistleblowing/ normative/cross-analysis/kb/architettura/simulazione/integrazioni/workflow/ audit ACN/registra incidente). - AIService.php navigationMapBlock: riscritta sul menu IN ALTO V3 (9 gruppi + pulsante "Registra incidente"), sync con topnav-v3.js NAV_GROUPS; ARIA non parla più di "barra laterale". - help.js: corrette le 3 menzioni "sidebar" → barra/menu in alto. - KB nis2_kb: nuovo docs/kb/V3_UI_NAVIGAZIONE.md + scripts/ingest-v3-ui-doc.php (ingerito host-side: 3 chunk SYSTEM, rimossi i chunk V2; ARIA RAG ora guida sul top-nav). - Cache-buster ?v=20260627n sui 3 JS modificati su tutte le pagine. Deploy: JS/HTML statici live via bind-mount; php-fpm reload (host + nis2-app) per AIService. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
878 lines
42 KiB
PHP
878 lines
42 KiB
PHP
<?php
|
||
/**
|
||
* NIS2 Agile - AI Service
|
||
*
|
||
* Integrazione con Anthropic Claude API per:
|
||
* - Analisi gap analysis
|
||
* - Suggerimenti rischi
|
||
* - Generazione policy
|
||
* - Classificazione incidenti
|
||
* - Q&A NIS2
|
||
*/
|
||
|
||
class AIService
|
||
{
|
||
private string $apiKey;
|
||
private string $model;
|
||
private int $maxTokens;
|
||
private string $baseUrl = 'https://api.anthropic.com/v1/messages';
|
||
|
||
public function __construct()
|
||
{
|
||
$this->apiKey = ANTHROPIC_API_KEY;
|
||
$this->model = ANTHROPIC_MODEL;
|
||
$this->maxTokens = ANTHROPIC_MAX_TOKENS;
|
||
|
||
if (empty($this->apiKey) || $this->apiKey === 'sk-ant-xxxxx') {
|
||
throw new RuntimeException('ANTHROPIC_API_KEY non configurata');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Blocco "fonti certe" da iniettare nei system prompt.
|
||
* Elenca le fonti normative autoritative e impone di citarle, vietando
|
||
* riferimenti inventati. (Richiesta utente 2026-05-29 - grounding su fonti certe.)
|
||
*/
|
||
private function authoritativeSourcesBlock(): string
|
||
{
|
||
static $sources = null;
|
||
if ($sources === null) {
|
||
$sources = @include __DIR__ . '/../config/nis2_sources.php';
|
||
if (!is_array($sources)) $sources = [];
|
||
}
|
||
if (empty($sources)) return '';
|
||
|
||
$lines = [];
|
||
foreach ($sources as $s) {
|
||
$lines[] = '- ' . $s['citation'] . ' — ' . $s['authority'];
|
||
}
|
||
return "\n## FONTI NORMATIVE CERTE (cita SEMPRE quella pertinente)\n"
|
||
. implode("\n", $lines)
|
||
. "\n\nREGOLE SULLE FONTI (vincolanti):\n"
|
||
. "1. Ogni affermazione normativa DEVE essere ancorata a una di queste fonti, citata esplicitamente (es. \"ai sensi dell'art. 25 del D.Lgs. 138/2024\" o \"Determinazione ACN n. 164179/2025, Allegato 3\").\n"
|
||
. "2. NON inventare numeri di articolo, determine, allegati o date: se non sei certo, dichiaralo e invita a verificare la fonte ufficiale.\n"
|
||
. "3. Per gli OBBLIGHI operativi cita gli articoli del D.Lgs. 138/2024 (la Direttiva (UE) 2022/2555 e' recepita dal decreto: NON citarne gli articoli per gli obblighi). Mappatura: governance/organi di amministrazione e direttivi = art. 23; misure di gestione del rischio = art. 24; notifica degli incidenti significativi (preallarme 24h / notifica 72h / relazione finale entro 1 mese) = art. 25. La Direttiva si cita SOLO per principi/considerando.\n"
|
||
. "4. ORIENTAMENTO NON VINCOLANTE: le tue risposte forniscono supporto operativo e orientamento, NON costituiscono consulenza legale ne parere professionale vincolante. Dove la norma richiede una valutazione (perimetro di applicabilita, criticita di un fornitore, significativita di un incidente) dichiara esplicitamente che e' una valutazione da confermare con il referente compliance/legale dell'organizzazione (cfr. art. 22 GDPR per decisioni con effetti giuridici).\n"
|
||
. "5. ENISA, NIST e ISO sono best practice di settore, NON fonti normative vincolanti: gli obblighi italiani derivano da Direttiva (UE) 2022/2555, D.Lgs. 138/2024 e Determinazioni ACN.\n";
|
||
}
|
||
|
||
/**
|
||
* Mappa REALE della barra laterale dell'applicazione, da iniettare nel
|
||
* system prompt dell'assistente ARIA cosi' che le indicazioni di navigazione
|
||
* usino i nomi ESATTI del menu e non vengano inventati moduli/fasi inesistenti.
|
||
*
|
||
* ⚠️ TENERE SINCRONIZZATA con la fonte di verita' del menu V3:
|
||
* public/js/topnav-v3.js (array NAV_GROUPS). Se aggiungi/rinomini una voce
|
||
* di menu li', aggiorna anche questo elenco.
|
||
* (Ticket #424, 2026-06-23: ARIA citava voci di menu inesistenti.
|
||
* Aggiornato 2026-06-27 al menu IN ALTO V3 — prima era la sidebar V2.)
|
||
*/
|
||
private function navigationMapBlock(): string
|
||
{
|
||
return "\n## MAPPA DELLA NAVIGAZIONE (menu IN ALTO dell'app — interfaccia V3)\n"
|
||
. "L'app ha una BARRA IN ALTO (non piu' una barra laterale): a sinistra logo e ricerca, a destra il pulsante ROSSO \"Registra incidente\", il menu utente (Impostazioni, Esci) e il selettore lingua IT/EN. Sotto, una barra di NAVIGAZIONE con voci a tendina (mega-menu).\n"
|
||
. "Voci dirette: \"Dashboard\" · \"Calendario\".\n"
|
||
. "Menu \"Stakeholders\": Stakeholders · Supply-Chain · Attivita' su Stakeholders.\n"
|
||
. "Menu \"Struttura interna\": Organigramma · Competenze · GAP Analysis · Matrice RACI · Formazione.\n"
|
||
. "Menu \"Rischi\": Mappa dei rischi · Inventario · Policy · Connettori Discovery.\n"
|
||
. "Menu \"Monitoraggio\": Segnalazioni ricevute · Audit interni · Audit ACN · Controlli periodici · Riesame di Direzione · Scadenziario.\n"
|
||
. "Menu \"Lex e ISO\": Normative · Modello SGSI (ISO 27001) · Misure e requisiti ACN.\n"
|
||
. "Menu \"AI\": AI Cross-Analysis · Knowledge Base.\n"
|
||
. "Menu \"Guida\": Guida all'uso · Compliance Journey · Integrazioni Esterne · Impostazioni · Architettura · Simulazione Demo.\n"
|
||
. "Incidenti: si raggiungono dal pulsante ROSSO \"Registra incidente\" in alto a destra.\n"
|
||
. "\nREGOLE DI NAVIGAZIONE (vincolanti):\n"
|
||
. "1. Quando indichi DOVE cliccare o COME raggiungere una funzione, usa ESCLUSIVAMENTE i nomi delle voci qui sopra, esattamente come scritti, indicando il menu (es. \"nel menu in alto Rischi, voce Inventario\").\n"
|
||
. "2. NON inventare nomi di menu, \"fasi\", codici (es. \"A4\", \"Fase 4.1\") o moduli che NON compaiono in questo elenco. NON parlare di \"barra laterale\"/\"sidebar\": il menu e' IN ALTO.\n"
|
||
. "3. L'inventario degli asset si trova in: menu Rischi > Inventario. La valutazione del rischio in: menu Rischi > Mappa dei rischi. Misure e requisiti in: menu Lex e ISO > Misure e requisiti ACN. Per registrare un incidente: pulsante rosso \"Registra incidente\" in alto.\n"
|
||
. "4. Se l'utente cerca una funzione che non corrisponde ad alcuna voce, dillo chiaramente e suggerisci la voce piu' vicina, senza inventarne una.\n"
|
||
. "5. NON citare MAI nomi di file (es. \"rischi.html\", \"assets.html\") nE URL interni: l'utente naviga dalle voci di menu qui sopra, non dai file.\n"
|
||
. "6. NON inventare pulsanti, campi o passaggi che non esistono. Se non sei certo che una funzione/pagina esista, NON darla per scontata: dillo e invita ad aprire la guida \"?\" della pagina; usa solo le funzioni descritte nel contesto/guida della pagina, se forniti.\n";
|
||
}
|
||
|
||
/**
|
||
* Griglia ESATTA delle combo della finestra «Valuta Rilevanza NIS2»
|
||
* (Inventario asset), generata dalla fonte di verita' AssetScoringService::GRID.
|
||
* Iniettata nel system prompt di ARIA cosi' che, quando l'utente chiede del
|
||
* significato di una combo/criterio o dei punteggi, ARIA usi i valori REALI
|
||
* della finestra invece di inventarne (ticket #429, 2026-06-24: ARIA dava una
|
||
* risposta sulle combo "non corrispondente alla finestra").
|
||
*/
|
||
private function relevanceGridBlock(): string
|
||
{
|
||
require_once __DIR__ . '/AssetScoringService.php';
|
||
if (!class_exists('AssetScoringService')) return '';
|
||
|
||
$lines = [];
|
||
$lines[] = "\n## RILEVANZA NIS2 — combo della finestra «Valuta Rilevanza NIS2» (Inventario asset)";
|
||
$lines[] = "Questa e' la griglia ESATTA della finestra di valutazione: 6 criteri pesati, punteggio totale 0-100, asset RILEVANTE NIS2 se il totale e' >= "
|
||
. AssetScoringService::RELEVANCE_THRESHOLD . " (requisito GV.OC-04). Quando l'utente chiede il significato di una combo, di un'opzione o dei punteggi, usa SOLO queste voci e questi valori, NON inventarne altri.";
|
||
foreach (AssetScoringService::GRID as $crit) {
|
||
$lines[] = sprintf("\n%s (max %d punti) — %s", $crit['label'], $crit['max'], $crit['help']);
|
||
foreach ($crit['options'] as $opt) {
|
||
$lines[] = sprintf(" - %s (%d pt): %s", $opt['label'], $opt['points'], $opt['desc']);
|
||
}
|
||
}
|
||
return implode("\n", $lines) . "\n";
|
||
}
|
||
|
||
/**
|
||
* Snapshot LIVE dei dati dell'organizzazione dell'utente, da iniettare nel
|
||
* system prompt di ARIA così può rispondere su numeri SPECIFICI (rischi,
|
||
* incidenti, asset, score...) e non solo su normativa/guida.
|
||
*
|
||
* SICUREZZA: chiamato SOLO dopo verifica membership (AiController) → scope
|
||
* rigoroso `organization_id = $orgId`. PRIVACY: niente nome/fatturato dell'org
|
||
* (coerente con l'anonimizzazione già in uso); solo aggregati + titoli operativi.
|
||
* Ogni query è in try/catch: una colonna assente non rompe ARIA (degrada).
|
||
*/
|
||
private function orgDataSnapshotBlock(int $orgId): string
|
||
{
|
||
if ($orgId <= 0) return '';
|
||
$q = function ($sql, $p = []) { try { return Database::fetchOne($sql, $p); } catch (\Throwable $e) { return null; } };
|
||
$all = function ($sql, $p = []) { try { return Database::fetchAll($sql, $p); } catch (\Throwable $e) { return []; } };
|
||
|
||
$org = $q("SELECT sector, entity_type, voluntary_compliance FROM organizations WHERE id=?", [$orgId]);
|
||
$asmt = $q("SELECT overall_score FROM assessments WHERE organization_id=? AND status='completed' ORDER BY completed_at DESC LIMIT 1", [$orgId]);
|
||
$risk = $q("SELECT COUNT(*) tot, SUM(inherent_risk_score>=20) ch, SUM(inherent_risk_score BETWEEN 10 AND 19) med, SUM(inherent_risk_score<10) low FROM risks WHERE organization_id=? AND status!='closed'", [$orgId]);
|
||
$top = $all("SELECT title, inherent_risk_score s FROM risks WHERE organization_id=? AND status!='closed' ORDER BY inherent_risk_score DESC LIMIT 5", [$orgId]);
|
||
$inc = $q("SELECT COUNT(*) c FROM incidents WHERE organization_id=? AND status NOT IN ('closed','post_mortem')", [$orgId]);
|
||
$asTot = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND status='active'", [$orgId]);
|
||
$asRel = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND is_nis2_relevant=1", [$orgId]);
|
||
$sup = $q("SELECT COUNT(*) c FROM suppliers WHERE organization_id=? AND criticality IN ('high','critical') AND security_requirements_met=0", [$orgId]);
|
||
$nc = $q("SELECT COUNT(*) c FROM non_conformities WHERE organization_id=? AND status NOT IN ('closed','resolved','verified')", [$orgId]);
|
||
$tr = $q("SELECT COUNT(*) tot, SUM(status='completed') done, SUM(status='overdue') ovd FROM training_assignments WHERE organization_id=?", [$orgId]);
|
||
$pol = $all("SELECT status, COUNT(*) n FROM policies WHERE organization_id=? GROUP BY status", [$orgId]);
|
||
|
||
$L = [];
|
||
$L[] = "\n## DATI DELL'ORGANIZZAZIONE DELL'UTENTE (snapshot live — SOLO la sua org)";
|
||
$L[] = "Usa questi numeri per risposte specifiche sulla sua situazione. Sono dati riservati della sua organizzazione: non confrontarli con altre org. Se un dato è 0/assente, dillo con chiarezza.";
|
||
if ($org) $L[] = "- Classificazione: " . ($org['entity_type'] ?: 'n/d') . (!empty($org['voluntary_compliance']) ? ' (adesione volontaria)' : '') . ($org['sector'] ? ", settore " . $org['sector'] : '');
|
||
$L[] = "- Compliance score (ultimo assessment): " . ($asmt && $asmt['overall_score'] !== null ? round((float)$asmt['overall_score']) . '%' : 'nessun assessment completato');
|
||
if ($risk) $L[] = "- Rischi aperti: " . (int)$risk['tot'] . " (critico/alto " . (int)$risk['ch'] . ", medio " . (int)$risk['med'] . ", basso " . (int)$risk['low'] . ")";
|
||
if ($top) $L[] = " Top rischi: " . implode('; ', array_map(fn($x) => $x['title'] . ' (' . (int)$x['s'] . ')', $top));
|
||
$L[] = "- Incidenti aperti: " . (int)($inc['c'] ?? 0);
|
||
$L[] = "- Asset attivi: " . (int)($asTot['c'] ?? 0) . " (rilevanti NIS2: " . (int)($asRel['c'] ?? 0) . ")";
|
||
$L[] = "- Fornitori critici con requisiti sicurezza NON soddisfatti: " . (int)($sup['c'] ?? 0);
|
||
$L[] = "- Non conformità aperte: " . (int)($nc['c'] ?? 0);
|
||
if ($tr && (int)$tr['tot'] > 0) $L[] = "- Formazione: " . (int)$tr['done'] . "/" . (int)$tr['tot'] . " completate, " . (int)$tr['ovd'] . " scadute";
|
||
if ($pol) $L[] = "- Policy: " . implode(', ', array_map(fn($x) => $x['status'] . ' ' . $x['n'], $pol));
|
||
|
||
return implode("\n", $L) . "\n";
|
||
}
|
||
|
||
/**
|
||
* Analizza risultati gap analysis e genera raccomandazioni
|
||
*/
|
||
public function analyzeGapAssessment(array $organization, array $responses, float $overallScore): array
|
||
{
|
||
$responseSummary = $this->summarizeResponses($responses);
|
||
|
||
// Anonimizzazione: non inviare nome org né fatturato esatto ad API esterna
|
||
$employeeRange = $this->employeeRange((int)($organization['employee_count'] ?? 0));
|
||
|
||
$prompt = <<<PROMPT
|
||
Sei un esperto consulente di cybersecurity specializzato nella Direttiva NIS2 (EU 2022/2555) e nel D.Lgs. 138/2024 italiano.
|
||
|
||
Analizza i risultati della gap analysis per l'organizzazione seguente e fornisci raccomandazioni dettagliate.
|
||
|
||
## Organizzazione (dati anonimizzati)
|
||
- Settore: {$organization['sector']}
|
||
- Tipo entità NIS2: {$organization['entity_type']}
|
||
- Dimensione: {$employeeRange}
|
||
|
||
## Risultati Assessment (Score: {$overallScore}%)
|
||
|
||
{$responseSummary}
|
||
|
||
## Istruzioni
|
||
Fornisci la tua analisi in formato JSON con questa struttura:
|
||
{
|
||
"executive_summary": "Riepilogo esecutivo (3-4 frasi)",
|
||
"risk_level": "low|medium|high|critical",
|
||
"top_priorities": [
|
||
{
|
||
"area": "Nome area",
|
||
"nis2_article": "21.2.x",
|
||
"current_status": "Breve descrizione stato attuale",
|
||
"recommendation": "Azione raccomandata specifica",
|
||
"effort": "low|medium|high",
|
||
"timeline": "Tempistica suggerita"
|
||
}
|
||
],
|
||
"strengths": ["Punto di forza 1", "Punto di forza 2"],
|
||
"quick_wins": ["Azione rapida 1", "Azione rapida 2"],
|
||
"compliance_roadmap": [
|
||
{"phase": 1, "title": "Titolo fase", "actions": ["Azione 1"], "duration": "X mesi"}
|
||
]
|
||
}
|
||
|
||
Rispondi SOLO con il JSON, senza testo aggiuntivo.
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt);
|
||
return $this->parseJsonResponse($response);
|
||
}
|
||
|
||
/**
|
||
* Suggerisce rischi basati su settore e asset
|
||
*/
|
||
public function suggestRisks(array $organization, array $assets = []): array
|
||
{
|
||
$assetList = empty($assets) ? 'Non disponibile' : json_encode($assets, JSON_UNESCAPED_UNICODE);
|
||
// Anonimizzazione coerente con gli altri metodi: range invece del numero esatto.
|
||
$dimensione = $this->employeeRange($organization['employee_count'] ?? 0);
|
||
|
||
$prompt = <<<PROMPT
|
||
Sei un esperto di cybersecurity risk assessment. Genera una lista di rischi cyber per questa organizzazione.
|
||
|
||
## Organizzazione
|
||
- Settore: {$organization['sector']}
|
||
- Tipo entità NIS2: {$organization['entity_type']}
|
||
- Dimensione: {$dimensione}
|
||
|
||
## Asset IT/OT
|
||
{$assetList}
|
||
|
||
Fornisci 10 rischi in formato JSON:
|
||
[
|
||
{
|
||
"title": "Titolo rischio",
|
||
"description": "Descrizione dettagliata",
|
||
"category": "cyber|operational|compliance|supply_chain|physical|human",
|
||
"threat_source": "Fonte della minaccia",
|
||
"vulnerability": "Vulnerabilità sfruttata",
|
||
"likelihood": 1-5,
|
||
"impact": 1-5,
|
||
"nis2_article": "21.2.x",
|
||
"suggested_treatment": "mitigate|accept|transfer|avoid",
|
||
"mitigation_actions": ["Azione 1", "Azione 2"]
|
||
}
|
||
]
|
||
|
||
Rispondi SOLO con il JSON array.
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt);
|
||
return $this->parseJsonResponse($response);
|
||
}
|
||
|
||
/**
|
||
* Genera bozza di policy
|
||
*/
|
||
public function generatePolicy(string $category, array $organization, ?array $assessmentContext = null): array
|
||
{
|
||
$context = $assessmentContext ? json_encode($assessmentContext, JSON_UNESCAPED_UNICODE) : 'Non disponibile';
|
||
|
||
$prompt = <<<PROMPT
|
||
Sei un esperto di information security policy writing. Genera una policy aziendale per la categoria "{$category}" conforme alla Direttiva NIS2.
|
||
|
||
## Organizzazione (dati anonimizzati)
|
||
- Settore: {$organization['sector']}
|
||
- Tipo entità NIS2: {$organization['entity_type']}
|
||
|
||
## Contesto Assessment
|
||
{$context}
|
||
|
||
Genera la policy in formato JSON:
|
||
{
|
||
"title": "Titolo completo della policy",
|
||
"version": "1.0",
|
||
"category": "{$category}",
|
||
"nis2_article": "21.2.x",
|
||
"content": "Contenuto completo della policy in formato Markdown con sezioni: 1. Scopo, 2. Ambito di applicazione, 3. Responsabilità, 4. Definizioni, 5. Policy (regole specifiche), 6. Procedure operative, 7. Controlli, 8. Non conformità, 9. Revisione",
|
||
"review_period_months": 12
|
||
}
|
||
|
||
La policy deve essere in italiano, professionale, specifica per il settore dell'organizzazione, e conforme ai requisiti NIS2.
|
||
Rispondi SOLO con il JSON.
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt);
|
||
return $this->parseJsonResponse($response);
|
||
}
|
||
|
||
/**
|
||
* Genera una bozza di documented information SGSI (ISO/IEC 27001:2022).
|
||
* Es. doc_type: policy_sgsi, dichiarazione_applicabilita, procedura_incident,
|
||
* politica_controllo_accessi, procedura_continuita, ecc.
|
||
*
|
||
* Ritorna ['title'=>, 'body_html'=>]. La bozza riporta un disclaimer e cita
|
||
* le fonti certe (la conformita normativa primaria in Italia resta NIS2 /
|
||
* D.Lgs. 138/2024; ISO 27001/27002/27017/27018 sono best practice).
|
||
*/
|
||
public function generateIsmsDocument(string $docType, array $organization, ?array $context = null): array
|
||
{
|
||
$sector = $organization['sector'] ?? 'n/d';
|
||
$entityType = $organization['entity_type'] ?? 'n/d';
|
||
$ctx = $context ? json_encode($context, JSON_UNESCAPED_UNICODE) : 'Non disponibile';
|
||
$sourcesBlock = $this->authoritativeSourcesBlock();
|
||
|
||
$prompt = <<<PROMPT
|
||
Sei un esperto consulente ISO/IEC 27001:2022 (SGSI / Information Security Management System). Redigi una bozza di documento di tipo "{$docType}" per il Sistema di Gestione della Sicurezza delle Informazioni dell'organizzazione.
|
||
|
||
## Organizzazione (dati anonimizzati)
|
||
- Settore: {$sector}
|
||
- Tipo entita NIS2: {$entityType}
|
||
|
||
## Contesto SGSI
|
||
{$ctx}
|
||
{$sourcesBlock}
|
||
|
||
## Istruzioni
|
||
- Documento in italiano, professionale, coerente con ISO/IEC 27001:2022 e ISO/IEC 27002:2022. Se il contesto indica uso del cloud, considera ISO/IEC 27017:2015; se tratta dati personali in cloud, ISO/IEC 27018:2019.
|
||
- Ricorda che gli obblighi normativi italiani derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO e best practice non vincolante.
|
||
- Struttura tipica: Scopo, Ambito, Ruoli e responsabilita, Riferimenti normativi, Contenuto/Regole, Controlli ISO 27001 collegati (Annex A), Monitoraggio e riesame.
|
||
|
||
Rispondi SOLO con JSON:
|
||
{
|
||
"title": "Titolo del documento",
|
||
"content": "Contenuto completo in HTML semplice (h2/h3/p/ul/li/strong), pronto per essere salvato e modificato"
|
||
}
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt, 'Sei un redattore di documentazione SGSI ISO 27001. Rispondi solo con JSON valido.');
|
||
$parsed = $this->parseJsonResponse($response);
|
||
return [
|
||
'title' => (string) ($parsed['title'] ?? $docType),
|
||
'body_html' => (string) ($parsed['content'] ?? $parsed['body_html'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Classifica un incidente e suggerisce severity
|
||
*/
|
||
public function classifyIncident(string $title, string $description, array $organization): array
|
||
{
|
||
$entityType = $organization['entity_type'] ?? 'important';
|
||
// Allegato 4 = soggetti ESSENZIALI, Allegato 3 = soggetti IMPORTANTI
|
||
// (verificato su Determina ACN 164179/2025, Allegati 3 e 4):
|
||
// gli importanti NON hanno l'obbligo sugli incidenti ricorrenti (IS-4).
|
||
$isEssential = ($entityType === 'essential');
|
||
$allowedIs = $isEssential ? 'IS-1|IS-2|IS-3|IS-4' : 'IS-1|IS-2|IS-3';
|
||
$allegato = $isEssential ? 'Allegato 4 (soggetti essenziali)' : 'Allegato 3 (soggetti importanti)';
|
||
$sourcesBlock = $this->authoritativeSourcesBlock();
|
||
|
||
$prompt = <<<PROMPT
|
||
Sei un analista di incident response NIS2. Classifica il seguente incidente secondo il quadro normativo italiano.
|
||
|
||
## Quadro normativo di riferimento (cita la fonte pertinente in ogni campo motivazionale)
|
||
- Obbligo di notifica: art. 25 D.Lgs. 138/2024 (la notifica degli incidenti significativi e' all'art. 25 del decreto; l'art. 23 del decreto riguarda gli organi di amministrazione e direttivi).
|
||
- Classificazione incidenti significativi e tempistiche: Determinazione ACN n. 164179/2025, {$allegato}.
|
||
- Tempistiche: preallarme entro 24h e notifica completa entro 72h dalla conoscenza dell'incidente; relazione finale entro 1 mese DALLA NOTIFICA delle 72h (non dalla data dell'incidente).
|
||
- Tipologie significative applicabili a questo soggetto ({$entityType}): {$allowedIs}.
|
||
{$sourcesBlock}
|
||
|
||
## Incidente
|
||
- Titolo: {$title}
|
||
- Descrizione: {$description}
|
||
|
||
## Organizzazione
|
||
- Settore: {$organization['sector']}
|
||
- Tipo entità: {$entityType}
|
||
|
||
Rispondi in formato JSON:
|
||
{
|
||
"classification": "cyber_attack|data_breach|system_failure|human_error|natural_disaster|supply_chain|other",
|
||
"nis2_incident_type": "{$allowedIs}|none",
|
||
"severity": "low|medium|high|critical",
|
||
"is_significant": true/false,
|
||
"significance_reason": "Motivo con citazione esplicita della fonte (es. Determina ACN 164179/2025, {$allegato})",
|
||
"requires_csirt_notification": true/false,
|
||
"notification_basis": "Riferimento normativo dell'obbligo (es. art. 25 D.Lgs. 138/2024)",
|
||
"suggested_actions": ["Azione immediata 1", "Azione immediata 2"],
|
||
"potential_impact": "Descrizione impatto potenziale",
|
||
"iocs_to_check": ["Indicatore 1", "Indicatore 2"]
|
||
}
|
||
|
||
Non inventare riferimenti: usa solo le fonti elencate sopra. Rispondi SOLO con il JSON.
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt);
|
||
return $this->parseJsonResponse($response);
|
||
}
|
||
|
||
/**
|
||
* Chiama Anthropic API
|
||
*/
|
||
private function callAPI(string $prompt, ?string $systemPrompt = null): string
|
||
{
|
||
$system = $systemPrompt ?? <<<SYSTEM
|
||
Sei un esperto consulente di cybersecurity e compliance NIS2, integrato nella piattaforma SaaS "NIS2 Agile".
|
||
|
||
Moduli della piattaforma che conosci:
|
||
- Gap Analysis (80 domande, 10 categorie di misure di gestione del rischio, art. 24 D.Lgs. 138/2024)
|
||
- Risk Management (registro rischi ISO 27005, matrice 5×5)
|
||
- Incident Management (notifica art. 25 D.Lgs. 138/2024: preallarme 24h, notifica completa 72h, relazione finale entro 1 mese dalla notifica 72h)
|
||
- Policy Management (generazione e approvazione policy NIS2)
|
||
- Supply Chain (valutazione fornitori, risk scoring)
|
||
- Training (assegnazione corsi, formazione organi di gestione art. 23 D.Lgs. 138/2024)
|
||
- Inventario (HW/SW e sistemi, beni critici, dipendenze)
|
||
- Audit & Report (controlli, evidenze, export CSV)
|
||
- NCR/CAPA (non conformità, azioni correttive)
|
||
- Whistleblowing (segnalazioni anomalie Art.32)
|
||
- Normative Updates (feed NIS2/ACN/DORA con ACK)
|
||
- Segnalazioni Feedback (bug reporting con risoluzione autonoma AI)
|
||
- Services API (endpoint pubblici per integrazioni B2B)
|
||
|
||
Rispondi sempre in italiano, in modo professionale, preciso e conciso.
|
||
Non includere dati identificativi dell'organizzazione nelle risposte.
|
||
SYSTEM;
|
||
|
||
// Grounding su fonti certe: applicato solo al prompt di default
|
||
// (i system prompt espliciti gestiscono le fonti per conto proprio).
|
||
if ($systemPrompt === null) {
|
||
$system .= "\n" . $this->authoritativeSourcesBlock();
|
||
}
|
||
|
||
$body = [
|
||
'model' => $this->model,
|
||
'max_tokens' => $this->maxTokens,
|
||
'system' => $system,
|
||
'messages' => [
|
||
['role' => 'user', 'content' => $prompt],
|
||
],
|
||
];
|
||
|
||
$ch = curl_init($this->baseUrl);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'x-api-key: ' . $this->apiKey,
|
||
'anthropic-version: 2023-06-01',
|
||
],
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($body),
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 120,
|
||
]);
|
||
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlError = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlError) {
|
||
throw new RuntimeException('Errore connessione AI: ' . $curlError);
|
||
}
|
||
|
||
if ($httpCode !== 200) {
|
||
$errorData = json_decode($response, true);
|
||
$errorMessage = $errorData['error']['message'] ?? 'Errore API sconosciuto';
|
||
throw new RuntimeException("Errore API AI ({$httpCode}): {$errorMessage}");
|
||
}
|
||
|
||
$data = json_decode($response, true);
|
||
|
||
if (!isset($data['content'][0]['text'])) {
|
||
throw new RuntimeException('Risposta AI non valida');
|
||
}
|
||
|
||
return $data['content'][0]['text'];
|
||
}
|
||
|
||
/**
|
||
* Converte numero dipendenti in range anonimizzato
|
||
*/
|
||
private function employeeRange(int $count): string
|
||
{
|
||
if ($count <= 0) return 'Non specificato';
|
||
if ($count <= 10) return 'Micro impresa (1-10 dipendenti)';
|
||
if ($count <= 50) return 'Piccola impresa (11-50 dipendenti)';
|
||
if ($count <= 250) return 'Media impresa (51-250 dipendenti)';
|
||
if ($count <= 1000) return 'Grande impresa (251-1000 dipendenti)';
|
||
return 'Grande organizzazione (>1000 dipendenti)';
|
||
}
|
||
|
||
/**
|
||
* Riassume le risposte dell'assessment per il prompt AI
|
||
*/
|
||
private function summarizeResponses(array $responses): string
|
||
{
|
||
$byCategory = [];
|
||
foreach ($responses as $r) {
|
||
$cat = $r['category'] ?? 'other';
|
||
if (!isset($byCategory[$cat])) {
|
||
$byCategory[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'na' => 0, 'total' => 0];
|
||
}
|
||
$byCategory[$cat]['total']++;
|
||
match ($r['response_value']) {
|
||
'implemented' => $byCategory[$cat]['implemented']++,
|
||
'partial' => $byCategory[$cat]['partial']++,
|
||
'not_implemented' => $byCategory[$cat]['not_implemented']++,
|
||
'not_applicable' => $byCategory[$cat]['na']++,
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
$summary = '';
|
||
foreach ($byCategory as $cat => $counts) {
|
||
$pct = $counts['total'] > 0
|
||
? round(($counts['implemented'] * 100 + $counts['partial'] * 50) / (($counts['total'] - $counts['na']) * 100) * 100)
|
||
: 0;
|
||
$summary .= "- {$cat}: {$pct}% (implementati: {$counts['implemented']}, parziali: {$counts['partial']}, non implementati: {$counts['not_implemented']})\n";
|
||
}
|
||
|
||
return $summary;
|
||
}
|
||
|
||
/**
|
||
* Parsing robusto della risposta JSON dall'AI
|
||
*/
|
||
private function parseJsonResponse(string $response): array
|
||
{
|
||
// Rimuovi eventuale markdown code blocks
|
||
$response = preg_replace('/^```(?:json)?\s*/m', '', $response);
|
||
$response = preg_replace('/\s*```\s*$/m', '', $response);
|
||
$response = trim($response);
|
||
|
||
$data = json_decode($response, true);
|
||
|
||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
error_log('[AI] JSON parse error: ' . json_last_error_msg() . ' | Response: ' . substr($response, 0, 500));
|
||
return ['error' => 'Impossibile analizzare la risposta AI', 'raw' => substr($response, 0, 1000)];
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* Classifica una segnalazione feedback/bug per il sistema di ticketing interno
|
||
*
|
||
* @param string $tipo Tipo segnalazione (bug|ux|funzionalita|domanda|altro)
|
||
* @param string $descrizione Testo della segnalazione (non contiene PII org)
|
||
* @return array ['categoria', 'priorita', 'suggerimento', 'risposta_utente']
|
||
*/
|
||
public function classifyFeedback(string $tipo, string $descrizione): array
|
||
{
|
||
$prompt = <<<PROMPT
|
||
Sei un assistente tecnico per una piattaforma SaaS di compliance NIS2.
|
||
Analizza la seguente segnalazione e rispondi SOLO con un oggetto JSON valido.
|
||
|
||
Tipo segnalazione: {$tipo}
|
||
Descrizione: {$descrizione}
|
||
|
||
Rispondi con questo JSON (senza markdown, solo JSON puro):
|
||
{
|
||
"categoria": "una tra: autenticazione, dashboard, rischi, incidenti, policy, assessment, formazione, fornitori, asset, report, impostazioni, performance, interfaccia, documentazione, altro",
|
||
"priorita": "una tra: alta, media, bassa",
|
||
"suggerimento": "suggerimento tecnico breve per il team di sviluppo (max 150 caratteri)",
|
||
"risposta_utente": "risposta leggibile e rassicurante per l'utente finale in italiano (max 200 caratteri)"
|
||
}
|
||
|
||
Criteri priorità:
|
||
- alta: blocca funzionalità critica (compliance, accesso, salvataggio dati)
|
||
- media: degrada l'esperienza ma esiste un workaround
|
||
- bassa: miglioria estetica o funzionalità secondaria
|
||
PROMPT;
|
||
|
||
$ch = curl_init($this->baseUrl);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'x-api-key: ' . $this->apiKey,
|
||
'anthropic-version: 2023-06-01',
|
||
],
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode([
|
||
'model' => $this->model,
|
||
'max_tokens' => 500,
|
||
'system' => 'Sei l\'assistente tecnico di NIS2 Agile, piattaforma SaaS per compliance NIS2. Conosci tutti i moduli: gap analysis, rischi, incidenti, policy, supply chain, formazione, asset, audit, NCR/CAPA, whistleblowing, normative, feedback. Rispondi SEMPRE con JSON puro, senza markdown né testo aggiuntivo.',
|
||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||
]),
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
]);
|
||
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlError = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlError || $httpCode !== 200) {
|
||
throw new RuntimeException("classifyFeedback AI error [{$httpCode}]: {$curlError}");
|
||
}
|
||
|
||
$data = json_decode($response, true);
|
||
$text = $data['content'][0]['text'] ?? '';
|
||
|
||
$result = $this->parseJsonResponse($text);
|
||
|
||
// Normalizza priorità
|
||
$validP = ['alta', 'media', 'bassa'];
|
||
if (!in_array($result['priorita'] ?? '', $validP)) {
|
||
$result['priorita'] = 'media';
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Analisi cross-organizzazione per consulenti (L4)
|
||
* Riceve dati già aggregati e anonimizzati dal controller.
|
||
* NON deve mai ricevere nomi org, P.IVA o dati identificativi.
|
||
*/
|
||
public function crossOrgAnalysis(int $orgCount, array $aggregated, string $question): array
|
||
{
|
||
$context = $this->buildCrossOrgContext($aggregated, $orgCount);
|
||
|
||
$system = <<<SYSTEM
|
||
Sei un analista di cybersecurity e compliance NIS2 specializzato nell'analisi comparativa multi-organizzazione.
|
||
Rispondi sempre in italiano, in modo professionale e sintetico.
|
||
|
||
REGOLA FONDAMENTALE DI PRIVACY (non derogabile):
|
||
- Non fare mai riferimento a organizzazioni specifiche o identificabili.
|
||
- Rispondi SOLO con statistiche aggregate e trend generali.
|
||
- Se una risposta richiederebbe identificare una singola organizzazione, rifiuta con: "Dato non disponibile per protezione della privacy".
|
||
- Non inventare dati non presenti nel contesto.
|
||
SYSTEM;
|
||
|
||
$prompt = <<<PROMPT
|
||
## Portfolio analizzato: {$orgCount} organizzazioni (dati aggregati e anonimizzati)
|
||
|
||
{$context}
|
||
|
||
## Domanda del consulente
|
||
{$question}
|
||
|
||
Rispondi in formato JSON:
|
||
{
|
||
"answer": "Risposta principale dettagliata (3-6 paragrafi)",
|
||
"key_findings": ["Risultato chiave 1", "Risultato chiave 2"],
|
||
"recommendations": ["Raccomandazione pratica 1", "Raccomandazione pratica 2"],
|
||
"risk_areas": ["Area critica 1", "Area critica 2"],
|
||
"benchmark_note": "Nota comparativa settoriale se rilevante, altrimenti null",
|
||
"privacy_note": null
|
||
}
|
||
|
||
Rispondi SOLO con il JSON.
|
||
PROMPT;
|
||
|
||
$response = $this->callAPI($prompt, $system);
|
||
return $this->parseJsonResponse($response);
|
||
}
|
||
|
||
/**
|
||
* Costruisce il contesto aggregato per il prompt cross-org
|
||
*/
|
||
private function buildCrossOrgContext(array $d, int $orgCount): string
|
||
{
|
||
$lines = [];
|
||
|
||
// Distribuzione settoriale
|
||
if (!empty($d['by_sector'])) {
|
||
$lines[] = '### Distribuzione Settoriale';
|
||
foreach ($d['by_sector'] as $sector => $count) {
|
||
$pct = round($count / $orgCount * 100);
|
||
$lines[] = "- {$sector}: {$count} org ({$pct}%)";
|
||
}
|
||
}
|
||
|
||
// Classificazione NIS2
|
||
if (!empty($d['by_entity_type'])) {
|
||
$lines[] = "\n### Classificazione NIS2";
|
||
foreach ($d['by_entity_type'] as $type => $count) {
|
||
$lines[] = "- {$type}: {$count} org";
|
||
}
|
||
}
|
||
|
||
// Compliance score
|
||
if (isset($d['avg_compliance_score'])) {
|
||
$lines[] = "\n### Compliance Score Medio: {$d['avg_compliance_score']}%";
|
||
if (isset($d['score_distribution'])) {
|
||
foreach ($d['score_distribution'] as $range => $count) {
|
||
$lines[] = "- {$range}: {$count} org";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Assessment
|
||
if (isset($d['assessments'])) {
|
||
$a = $d['assessments'];
|
||
$lines[] = "\n### Gap Assessment";
|
||
$lines[] = "- Org con assessment completato: {$a['with_completed']} / {$orgCount}";
|
||
if (!empty($a['avg_by_category'])) {
|
||
$lines[] = "- Score medio per categoria:";
|
||
foreach ($a['avg_by_category'] as $cat => $score) {
|
||
$lines[] = " - {$cat}: {$score}%";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rischi
|
||
if (isset($d['risks'])) {
|
||
$r = $d['risks'];
|
||
$lines[] = "\n### Rischi (aggregato)";
|
||
$lines[] = "- Totale rischi aperti: {$r['total_open']}";
|
||
$lines[] = "- Media rischi per org: {$r['avg_per_org']}";
|
||
if (!empty($r['by_severity'])) {
|
||
foreach ($r['by_severity'] as $sev => $count) {
|
||
$lines[] = "- Gravità {$sev}: {$count} rischi";
|
||
}
|
||
}
|
||
$lines[] = "- % con piano trattamento: {$r['pct_with_treatment']}%";
|
||
}
|
||
|
||
// Policy
|
||
if (isset($d['policies'])) {
|
||
$p = $d['policies'];
|
||
$lines[] = "\n### Policy";
|
||
$lines[] = "- Media policy approvate per org: {$p['avg_approved']}";
|
||
$lines[] = "- Org senza policy approvate: {$p['without_approved']}";
|
||
}
|
||
|
||
// Formazione
|
||
if (isset($d['training'])) {
|
||
$t = $d['training'];
|
||
$lines[] = "\n### Formazione";
|
||
$lines[] = "- Tasso completamento medio: {$t['avg_completion_rate']}%";
|
||
$lines[] = "- Org con completamento < 50%: {$t['below_50pct']}";
|
||
}
|
||
|
||
// Controlli
|
||
if (isset($d['controls'])) {
|
||
$c = $d['controls'];
|
||
$lines[] = "\n### Controlli ISO 27001 / NIS2";
|
||
$lines[] = "- % media implementazione: {$c['avg_implementation']}%";
|
||
if (!empty($c['weakest_categories'])) {
|
||
$lines[] = "- Categorie più deboli: " . implode(', ', $c['weakest_categories']);
|
||
}
|
||
}
|
||
|
||
// Incidenti
|
||
if (isset($d['incidents'])) {
|
||
$i = $d['incidents'];
|
||
$lines[] = "\n### Incidenti (ultimi 12 mesi)";
|
||
$lines[] = "- Totale incidenti: {$i['total']}";
|
||
$lines[] = "- Org con almeno 1 incidente: {$i['orgs_with_incidents']}";
|
||
if (!empty($i['by_severity'])) {
|
||
foreach ($i['by_severity'] as $sev => $count) {
|
||
$lines[] = "- Gravità {$sev}: {$count}";
|
||
}
|
||
}
|
||
}
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* Registra interazione AI nel database
|
||
*/
|
||
public function logInteraction(int $orgId, int $userId, string $type, string $promptSummary, string $responseSummary, int $tokensUsed = 0): void
|
||
{
|
||
Database::insert('ai_interactions', [
|
||
'organization_id' => $orgId,
|
||
'user_id' => $userId,
|
||
'interaction_type' => $type,
|
||
'prompt_summary' => substr($promptSummary, 0, 500),
|
||
'response_summary' => $responseSummary,
|
||
'tokens_used' => $tokensUsed,
|
||
'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 ARIA, l'assistente dell'applicazione NIS2 Agile, esperta 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"
|
||
. "## REGOLA ANTI-INVENZIONE (VINCOLANTE, priorità massima)\n"
|
||
. "NON inventare MAI record specifici — segnalazioni, reclami, ticket, incidenti, rischi, "
|
||
. "nomi di persone, date, citazioni, contenuti o esiti di gestione — che NON siano "
|
||
. "ESPLICITAMENTE presenti nei «DATI DELL'ORGANIZZAZIONE» o nel «Contesto documentale» qui forniti. "
|
||
. "In particolare: i RECLAMI / le SEGNALAZIONI / i TICKET di supporto dei singoli utenti (es. "
|
||
. "\"il reclamo di Mario Rossi\") sono gestiti in un sistema separato e NON ti sono accessibili. "
|
||
. "Se ti viene chiesto di analizzare un reclamo/segnalazione/ticket/incidente specifico e quel dato "
|
||
. "NON compare nei dati forniti, RISPONDI che non hai accesso a quel record e indica dove "
|
||
. "consultarlo nell'app (es. sezione Gestione > Segnalazioni o il pulsante di supporto), "
|
||
. "SENZA inventare alcun dettaglio. Meglio dire \"non ho quel dato\" che fabbricare una risposta.\n"
|
||
. $this->authoritativeSourcesBlock()
|
||
. $this->navigationMapBlock();
|
||
|
||
// Pagina corrente dell'utente (se inviata dal frontend): aiuta ARIA a
|
||
// dare indicazioni contestuali invece di rimandare a moduli a caso.
|
||
$page = trim((string) ($userContext['page'] ?? ''));
|
||
$pageId = trim((string) ($userContext['page_id'] ?? ''));
|
||
if ($page !== '' || $pageId !== '') {
|
||
$systemPrompt .= "\n## CONTESTO UTENTE\n"
|
||
. "L'utente si trova attualmente nella pagina: \"" . ($page !== '' ? $page : $pageId) . "\""
|
||
. ($pageId !== '' ? " (id: {$pageId})" : '') . ". "
|
||
. "Tieni conto di dove si trova quando dai indicazioni di navigazione.\n";
|
||
}
|
||
|
||
// ALLINEAMENTO ARIA ↔ HELP: inietta il testo dell'help contestuale "?" della
|
||
// pagina corrente (stessa fonte help.js che l'utente vede) così le risposte
|
||
// sono coerenti con la guida e non divergono. Fonte autorevole per la pagina.
|
||
$pageHelp = trim((string) ($userContext['page_help'] ?? ''));
|
||
if ($pageHelp !== '') {
|
||
$systemPrompt .= "\n## GUIDA CONTESTUALE DELLA PAGINA (stesso testo dell'help «?» che l'utente vede ora)\n"
|
||
. "Allinea le tue risposte a questa guida: è la fonte ufficiale per la pagina corrente; non contraddirla.\n"
|
||
. $pageHelp . "\n";
|
||
}
|
||
|
||
// Se l'utente e' nell'Inventario o la domanda riguarda la valutazione di
|
||
// rilevanza NIS2, inietta la griglia ESATTA delle combo (fonte di verita'
|
||
// AssetScoringService::GRID) per evitare risposte non corrispondenti alla
|
||
// finestra reale (ticket #429).
|
||
$onInventory = $pageId === 'assets' || stripos($page, 'asset') !== false || stripos($page, 'inventario') !== false;
|
||
if ($onInventory || preg_match('/rilevanz|combo|criticit|punteggio|scoring|valutazione|gv\.?oc/iu', $question)) {
|
||
$systemPrompt .= $this->relevanceGridBlock();
|
||
}
|
||
|
||
// DATI ORG dell'utente: iniettati SOLO se la membership è stata verificata
|
||
// (AiController imposta org_data_ok dopo il check user_organizations / super_admin)
|
||
// → scope rigoroso, niente leak cross-org da X-Organization-Id falsificato.
|
||
$snapOrgId = (int) ($userContext['organization_id'] ?? 0);
|
||
if ($snapOrgId > 0 && !empty($userContext['org_data_ok'])) {
|
||
$systemPrompt .= $this->orgDataSnapshotBlock($snapOrgId);
|
||
}
|
||
|
||
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,
|
||
];
|
||
}
|
||
}
|