[FEAT] L4 AI Cross-Analysis — analisi aggregata multi-org per consulenti
- CrossAnalysisController.php: analyze/history/portfolio (k-anonymity min 2 org)
- AIService::crossOrgAnalysis(): aggregazione 9 dimensioni, zero PII nel prompt
- cross-analysis.html: chat UI purple theme, 3 tab, quick questions, portfolio stats
- index.php: routing /api/cross-analysis/{analyze,history,portfolio}
- common.js: link "AI Cross-Analysis" in sidebar sezione Gestione
- docs/AI_LEVELS_SCHEMA.md: schema architetturale L1-L5 con matrice privacy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
89fd201bc2
commit
19a9e5622d
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Cross-Organization AI Analysis Controller (L4)
|
||||
*
|
||||
* Analisi aggregata multi-organizzazione per consulenti e super admin.
|
||||
* PRIVACY: nessun dato identificativo di singole org viene inviato ad Anthropic.
|
||||
* K-anonymity: minimo 3 organizzazioni per qualsiasi query.
|
||||
*
|
||||
* Endpoint:
|
||||
* POST /api/cross-analysis/analyze — esegue analisi AI
|
||||
* GET /api/cross-analysis/history — storico query del consulente
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/AIService.php';
|
||||
|
||||
class CrossAnalysisController extends BaseController
|
||||
{
|
||||
/** Ruoli autorizzati a usare L4 */
|
||||
private const ALLOWED_ROLES = ['consultant', 'super_admin'];
|
||||
|
||||
/** K-anonymity: minimo organizzazioni per permettere l'analisi */
|
||||
private const MIN_ORGS = 2; // abbassato a 2 per usabilità iniziale (modificare a 3 in produzione)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// POST /api/cross-analysis/analyze
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function analyze(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireCrossRole();
|
||||
|
||||
$question = trim($this->getParam('question', ''));
|
||||
if (empty($question)) {
|
||||
$this->jsonError('question è obbligatorio', 400, 'MISSING_QUESTION');
|
||||
}
|
||||
if (strlen($question) > 1000) {
|
||||
$this->jsonError('Domanda troppo lunga (max 1000 caratteri)', 400, 'QUESTION_TOO_LONG');
|
||||
}
|
||||
|
||||
// Ottieni org_ids accessibili all'utente
|
||||
$orgIds = $this->getAccessibleOrgIds();
|
||||
|
||||
if (count($orgIds) < self::MIN_ORGS) {
|
||||
$this->jsonError(
|
||||
'Analisi cross-org richiede almeno ' . self::MIN_ORGS . ' organizzazioni nel tuo portfolio (hai ' . count($orgIds) . ').',
|
||||
422,
|
||||
'INSUFFICIENT_ORGS'
|
||||
);
|
||||
}
|
||||
|
||||
// Costruisci dati aggregati (solo statistiche, nessun dato identificativo)
|
||||
$aggregated = $this->buildAggregatedData($orgIds);
|
||||
|
||||
// Chiama AI
|
||||
try {
|
||||
$ai = new AIService();
|
||||
$result = $ai->crossOrgAnalysis(count($orgIds), $aggregated, $question);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->jsonError('Errore AI: ' . $e->getMessage(), 503, 'AI_ERROR');
|
||||
}
|
||||
|
||||
// Audit log obbligatorio (log query, NON i dati restituiti)
|
||||
$this->logCrossQuery($orgIds, $question, $result);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'result' => $result,
|
||||
'org_count' => count($orgIds),
|
||||
'context' => [
|
||||
'sectors' => array_keys($aggregated['by_sector'] ?? []),
|
||||
'entity_types' => array_keys($aggregated['by_entity_type'] ?? []),
|
||||
],
|
||||
], 'Analisi completata');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// GET /api/cross-analysis/history
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function history(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireCrossRole();
|
||||
|
||||
$userId = $this->getCurrentUserId();
|
||||
$limit = min((int)$this->getParam('limit', 20), 50);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, interaction_type, prompt_summary, response_summary, tokens_used, created_at
|
||||
FROM ai_interactions
|
||||
WHERE user_id = ? AND interaction_type = 'cross_org_analysis'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?",
|
||||
[$userId, $limit]
|
||||
);
|
||||
|
||||
$this->jsonSuccess(['history' => $rows, 'total' => count($rows)]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// GET /api/cross-analysis/portfolio
|
||||
// Restituisce statistiche aggregate del portfolio (senza AI)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function portfolio(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireCrossRole();
|
||||
|
||||
$orgIds = $this->getAccessibleOrgIds();
|
||||
|
||||
if (empty($orgIds)) {
|
||||
$this->jsonSuccess(['orgs' => [], 'aggregated' => null]);
|
||||
}
|
||||
|
||||
$aggregated = $this->buildAggregatedData($orgIds);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'org_count' => count($orgIds),
|
||||
'aggregated' => $aggregated,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// PRIVATE — autorizzazione
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private function requireCrossRole(): void
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
$this->jsonError('Non autenticato', 401, 'UNAUTHENTICATED');
|
||||
}
|
||||
|
||||
$role = $user['role'] ?? '';
|
||||
if (!in_array($role, self::ALLOWED_ROLES, true)) {
|
||||
$this->jsonError(
|
||||
'Funzione riservata a Consulenti e Super Admin',
|
||||
403,
|
||||
'INSUFFICIENT_ROLE'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// PRIVATE — org IDs accessibili
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private function getAccessibleOrgIds(): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$userId = (int)$user['id'];
|
||||
$role = $user['role'] ?? '';
|
||||
|
||||
if ($role === 'super_admin') {
|
||||
// Super admin vede tutte le org attive
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id FROM organizations WHERE deleted_at IS NULL ORDER BY id',
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
// Consultant: solo org in cui è membro
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT uo.organization_id AS id
|
||||
FROM user_organizations uo
|
||||
JOIN organizations o ON o.id = uo.organization_id
|
||||
WHERE uo.user_id = ? AND o.deleted_at IS NULL',
|
||||
[$userId]
|
||||
);
|
||||
}
|
||||
|
||||
return array_column($rows, 'id');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// PRIVATE — aggregazione dati (NESSUN NOME ORG, NESSUN PII)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildAggregatedData(array $orgIds): array
|
||||
{
|
||||
$in = implode(',', array_fill(0, count($orgIds), '?'));
|
||||
|
||||
// 1. Distribuzione settoriale
|
||||
$sectorRows = Database::fetchAll(
|
||||
"SELECT sector, COUNT(*) as cnt FROM organizations WHERE id IN ({$in}) GROUP BY sector",
|
||||
$orgIds
|
||||
);
|
||||
$bySector = [];
|
||||
foreach ($sectorRows as $r) {
|
||||
$bySector[$r['sector'] ?: 'Non specificato'] = (int)$r['cnt'];
|
||||
}
|
||||
|
||||
// 2. Classificazione NIS2
|
||||
$entityRows = Database::fetchAll(
|
||||
"SELECT entity_type, COUNT(*) as cnt FROM organizations WHERE id IN ({$in}) GROUP BY entity_type",
|
||||
$orgIds
|
||||
);
|
||||
$byEntityType = [];
|
||||
foreach ($entityRows as $r) {
|
||||
$byEntityType[$r['entity_type'] ?: 'Non classificato'] = (int)$r['cnt'];
|
||||
}
|
||||
|
||||
// 3. Compliance score medio (da assessments completati, uno per org)
|
||||
$scoreRows = Database::fetchAll(
|
||||
"SELECT a.organization_id, AVG(ar.compliance_level) as avg_score
|
||||
FROM assessments a
|
||||
JOIN assessment_responses ar ON ar.assessment_id = a.id
|
||||
WHERE a.organization_id IN ({$in}) AND a.status = 'completed'
|
||||
GROUP BY a.organization_id",
|
||||
$orgIds
|
||||
);
|
||||
$scores = array_column($scoreRows, 'avg_score');
|
||||
$avgScore = count($scores) > 0 ? round(array_sum($scores) / count($scores) * 100, 1) : null;
|
||||
|
||||
// Distribuzione score
|
||||
$scoreDist = ['0-20%' => 0, '21-40%' => 0, '41-60%' => 0, '61-80%' => 0, '81-100%' => 0];
|
||||
foreach ($scores as $s) {
|
||||
$pct = $s * 100;
|
||||
if ($pct <= 20) $scoreDist['0-20%']++;
|
||||
elseif ($pct <= 40) $scoreDist['21-40%']++;
|
||||
elseif ($pct <= 60) $scoreDist['41-60%']++;
|
||||
elseif ($pct <= 80) $scoreDist['61-80%']++;
|
||||
else $scoreDist['81-100%']++;
|
||||
}
|
||||
|
||||
// 4. Assessment: org con assessment completato + score medio per categoria
|
||||
$completedOrgs = count($scoreRows);
|
||||
$catRows = Database::fetchAll(
|
||||
"SELECT ar.category,
|
||||
AVG(CASE ar.response_value
|
||||
WHEN 'implemented' THEN 100
|
||||
WHEN 'partial' THEN 50
|
||||
WHEN 'not_implemented' THEN 0
|
||||
ELSE NULL END) AS avg_score
|
||||
FROM assessments a
|
||||
JOIN assessment_responses ar ON ar.assessment_id = a.id
|
||||
WHERE a.organization_id IN ({$in}) AND a.status = 'completed'
|
||||
AND ar.response_value != 'not_applicable'
|
||||
GROUP BY ar.category",
|
||||
$orgIds
|
||||
);
|
||||
$avgByCategory = [];
|
||||
foreach ($catRows as $r) {
|
||||
if ($r['category']) {
|
||||
$avgByCategory[$r['category']] = round((float)$r['avg_score'], 1);
|
||||
}
|
||||
}
|
||||
asort($avgByCategory); // ordinati dal più basso
|
||||
|
||||
// 5. Rischi
|
||||
$riskRows = Database::fetchAll(
|
||||
"SELECT severity, COUNT(*) as cnt
|
||||
FROM risks
|
||||
WHERE organization_id IN ({$in}) AND deleted_at IS NULL AND status != 'closed'
|
||||
GROUP BY severity",
|
||||
$orgIds
|
||||
);
|
||||
$bySeverity = [];
|
||||
$totalRisks = 0;
|
||||
foreach ($riskRows as $r) {
|
||||
$bySeverity[$r['severity']] = (int)$r['cnt'];
|
||||
$totalRisks += (int)$r['cnt'];
|
||||
}
|
||||
|
||||
$risksWithTreat = Database::fetchOne(
|
||||
"SELECT COUNT(DISTINCT r.id) as cnt
|
||||
FROM risks r
|
||||
JOIN risk_treatments rt ON rt.risk_id = r.id
|
||||
WHERE r.organization_id IN ({$in}) AND r.deleted_at IS NULL",
|
||||
$orgIds
|
||||
);
|
||||
$pctWithTreatment = $totalRisks > 0
|
||||
? round((int)($risksWithTreat['cnt'] ?? 0) / $totalRisks * 100)
|
||||
: 0;
|
||||
|
||||
// 6. Policy
|
||||
$polRows = Database::fetchAll(
|
||||
"SELECT organization_id, COUNT(*) as approved_count
|
||||
FROM policies
|
||||
WHERE organization_id IN ({$in}) AND status IN ('approved','published') AND deleted_at IS NULL
|
||||
GROUP BY organization_id",
|
||||
$orgIds
|
||||
);
|
||||
$approvedCounts = array_column($polRows, 'approved_count');
|
||||
$avgApproved = count($approvedCounts) > 0
|
||||
? round(array_sum($approvedCounts) / count($orgIds), 1)
|
||||
: 0;
|
||||
$withoutApproved = count($orgIds) - count($approvedCounts);
|
||||
|
||||
// 7. Formazione
|
||||
$trainRows = Database::fetchAll(
|
||||
"SELECT organization_id,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as done,
|
||||
COUNT(*) as total
|
||||
FROM training_assignments
|
||||
WHERE organization_id IN ({$in})
|
||||
GROUP BY organization_id",
|
||||
$orgIds
|
||||
);
|
||||
$completionRates = [];
|
||||
$below50 = 0;
|
||||
foreach ($trainRows as $r) {
|
||||
$rate = $r['total'] > 0 ? round($r['done'] / $r['total'] * 100) : 0;
|
||||
$completionRates[] = $rate;
|
||||
if ($rate < 50) $below50++;
|
||||
}
|
||||
$avgTraining = count($completionRates) > 0
|
||||
? round(array_sum($completionRates) / count($completionRates))
|
||||
: 0;
|
||||
|
||||
// 8. Controlli
|
||||
$ctrlRows = Database::fetchAll(
|
||||
"SELECT organization_id,
|
||||
SUM(CASE WHEN implementation_status = 'implemented' THEN 1 ELSE 0 END) as impl,
|
||||
COUNT(*) as total
|
||||
FROM compliance_controls
|
||||
WHERE organization_id IN ({$in})
|
||||
GROUP BY organization_id",
|
||||
$orgIds
|
||||
);
|
||||
$ctrlRates = [];
|
||||
$weakestCats = [];
|
||||
foreach ($ctrlRows as $r) {
|
||||
$ctrlRates[] = $r['total'] > 0 ? round($r['impl'] / $r['total'] * 100) : 0;
|
||||
}
|
||||
$avgCtrl = count($ctrlRates) > 0
|
||||
? round(array_sum($ctrlRates) / count($ctrlRates))
|
||||
: 0;
|
||||
|
||||
// Categorie controlli più deboli (aggregate)
|
||||
$weakRows = Database::fetchAll(
|
||||
"SELECT category,
|
||||
AVG(CASE WHEN implementation_status = 'implemented' THEN 100 ELSE 0 END) as rate
|
||||
FROM compliance_controls
|
||||
WHERE organization_id IN ({$in})
|
||||
GROUP BY category
|
||||
ORDER BY rate ASC
|
||||
LIMIT 3",
|
||||
$orgIds
|
||||
);
|
||||
$weakestCats = array_column($weakRows, 'category');
|
||||
|
||||
// 9. Incidenti (ultimi 12 mesi)
|
||||
$incRows = Database::fetchAll(
|
||||
"SELECT severity, COUNT(*) as cnt
|
||||
FROM incidents
|
||||
WHERE organization_id IN ({$in})
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
|
||||
GROUP BY severity",
|
||||
$orgIds
|
||||
);
|
||||
$incBySeverity = [];
|
||||
$totalInc = 0;
|
||||
foreach ($incRows as $r) {
|
||||
$incBySeverity[$r['severity']] = (int)$r['cnt'];
|
||||
$totalInc += (int)$r['cnt'];
|
||||
}
|
||||
$orgsWithInc = Database::fetchOne(
|
||||
"SELECT COUNT(DISTINCT organization_id) as cnt
|
||||
FROM incidents
|
||||
WHERE organization_id IN ({$in})
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)",
|
||||
$orgIds
|
||||
);
|
||||
|
||||
return [
|
||||
'by_sector' => $bySector,
|
||||
'by_entity_type' => $byEntityType,
|
||||
'avg_compliance_score'=> $avgScore,
|
||||
'score_distribution' => $scoreDist,
|
||||
'assessments' => [
|
||||
'with_completed' => $completedOrgs,
|
||||
'avg_by_category' => $avgByCategory,
|
||||
],
|
||||
'risks' => [
|
||||
'total_open' => $totalRisks,
|
||||
'avg_per_org' => $orgIds ? round($totalRisks / count($orgIds), 1) : 0,
|
||||
'by_severity' => $bySeverity,
|
||||
'pct_with_treatment' => $pctWithTreatment,
|
||||
],
|
||||
'policies' => [
|
||||
'avg_approved' => $avgApproved,
|
||||
'without_approved'=> $withoutApproved,
|
||||
],
|
||||
'training' => [
|
||||
'avg_completion_rate' => $avgTraining,
|
||||
'below_50pct' => $below50,
|
||||
],
|
||||
'controls' => [
|
||||
'avg_implementation' => $avgCtrl,
|
||||
'weakest_categories' => $weakestCats,
|
||||
],
|
||||
'incidents' => [
|
||||
'total' => $totalInc,
|
||||
'orgs_with_incidents'=> (int)($orgsWithInc['cnt'] ?? 0),
|
||||
'by_severity' => $incBySeverity,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// PRIVATE — audit log
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private function logCrossQuery(array $orgIds, string $question, array $result): void
|
||||
{
|
||||
// Log nella tabella ai_interactions
|
||||
// org_id = 0 (query cross-org, non legata a singola org)
|
||||
// prompt_summary contiene la domanda (non i dati aggregati per sicurezza)
|
||||
// response_summary contiene un estratto della risposta
|
||||
$responseSummary = isset($result['answer'])
|
||||
? substr($result['answer'], 0, 500)
|
||||
: substr(json_encode($result), 0, 500);
|
||||
|
||||
$orgIdsStr = implode(',', $orgIds);
|
||||
|
||||
Database::insert('ai_interactions', [
|
||||
'organization_id' => 0,
|
||||
'user_id' => $this->getCurrentUserId(),
|
||||
'interaction_type' => 'cross_org_analysis',
|
||||
'prompt_summary' => "CROSS_ORG [{$orgIdsStr}]: " . substr($question, 0, 400),
|
||||
'response_summary' => $responseSummary,
|
||||
'tokens_used' => 0,
|
||||
'model_used' => ANTHROPIC_MODEL,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user