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. 23 della Direttiva (UE) 2022/2555\" 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. Preferisci sempre il riferimento normativo italiano (D.Lgs. 138/2024 + Determine ACN) per gli obblighi operativi, e la Direttiva UE per i principi.\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"; } /** * 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 = <<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); $prompt = <<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 = <<callAPI($prompt); return $this->parseJsonResponse($response); } /** * 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 = <<callAPI($prompt); return $this->parseJsonResponse($response); } /** * Chiama Anthropic API */ private function callAPI(string $prompt, ?string $systemPrompt = null): string { $system = $systemPrompt ?? <<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 = <<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 = <<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 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" . $this->authoritativeSourcesBlock(); 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, ]; } }