[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:
parent
89fd201bc2
commit
19a9e5622d
428
application/controllers/CrossAnalysisController.php
Normal file
428
application/controllers/CrossAnalysisController.php
Normal file
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -310,6 +310,154 @@ PROMPT;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
672
public/cross-analysis.html
Normal file
672
public/cross-analysis.html
Normal file
@ -0,0 +1,672 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Cross-Analysis — NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--nis2-cyan: #06B6D4;
|
||||
--nis2-cyan-light: #ecfeff;
|
||||
--l4-purple: #7c3aed;
|
||||
--l4-purple-bg: #f5f3ff;
|
||||
}
|
||||
|
||||
/* ── Chat container ─────────────────────────── */
|
||||
.chat-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 500px;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.chat-input-area {
|
||||
border-top: 1px solid var(--gray-200);
|
||||
padding: 16px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
|
||||
}
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chat-input-row textarea {
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
max-height: 140px;
|
||||
resize: vertical;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: var(--border-radius);
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.chat-input-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--l4-purple);
|
||||
}
|
||||
.btn-send {
|
||||
background: var(--l4-purple);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 14px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:hover:not(:disabled) { background: #6d28d9; }
|
||||
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Message bubbles ────────────────────────── */
|
||||
.msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.msg.user { flex-direction: row-reverse; }
|
||||
.msg-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg.user .msg-avatar { background: var(--l4-purple); color: white; }
|
||||
.msg.ai .msg-avatar { background: var(--gray-800); color: white; }
|
||||
.msg-body {
|
||||
max-width: 78%;
|
||||
flex: 1;
|
||||
}
|
||||
.msg.user .msg-body { display: flex; justify-content: flex-end; }
|
||||
.msg-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg.user .msg-bubble {
|
||||
background: var(--l4-purple);
|
||||
color: white;
|
||||
border-radius: var(--border-radius) 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
.msg.ai .msg-bubble {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-800);
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── AI response structured ─────────────────── */
|
||||
.ai-answer { margin-bottom: 12px; font-size: 0.88rem; line-height: 1.6; }
|
||||
.ai-section { margin-top: 12px; }
|
||||
.ai-section-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ai-section ul {
|
||||
margin: 0; padding: 0 0 0 16px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.ai-section ul li { margin-bottom: 3px; }
|
||||
.ai-benchmark {
|
||||
background: var(--l4-purple-bg);
|
||||
border-left: 3px solid var(--l4-purple);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--gray-700);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ai-privacy-note {
|
||||
background: #fef3c7;
|
||||
border-left: 3px solid var(--warning);
|
||||
padding: 6px 10px;
|
||||
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
|
||||
font-size: 0.78rem;
|
||||
color: #92400e;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.msg-time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--gray-400);
|
||||
margin-top: 3px;
|
||||
text-align: right;
|
||||
}
|
||||
.msg.ai .msg-time { text-align: left; }
|
||||
|
||||
/* ── Thinking bubble ────────────────────────── */
|
||||
.thinking-bubble {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 12px 16px;
|
||||
background: var(--gray-100);
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
|
||||
width: fit-content;
|
||||
}
|
||||
.thinking-dot {
|
||||
width: 7px; height: 7px;
|
||||
background: var(--gray-400);
|
||||
border-radius: 50%;
|
||||
animation: thinkBounce 1.2s infinite;
|
||||
}
|
||||
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes thinkBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* ── Portfolio summary card ─────────────────── */
|
||||
.portfolio-header {
|
||||
background: linear-gradient(135deg, #4c1d95, #6d28d9);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 18px 22px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.portfolio-stat {
|
||||
text-align: center;
|
||||
}
|
||||
.portfolio-stat .val { font-size: 1.8rem; font-weight: 800; color: #c4b5fd; }
|
||||
.portfolio-stat .lbl { font-size: 0.68rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* ── Quick questions ────────────────────────── */
|
||||
.quick-qs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.quick-q {
|
||||
background: var(--l4-purple-bg);
|
||||
color: var(--l4-purple);
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.77rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.quick-q:hover { background: #ede9fe; }
|
||||
|
||||
/* ── Privacy badge ──────────────────────────── */
|
||||
.privacy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────── */
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--gray-400);
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-empty i { font-size: 2.5rem; color: #c4b5fd; }
|
||||
.chat-empty h4 { color: var(--gray-600); font-size: 0.95rem; }
|
||||
.chat-empty p { font-size: 0.82rem; max-width: 360px; }
|
||||
|
||||
/* ── Sidebar tab ────────────────────────────── */
|
||||
.tab-bar { display: flex; border-bottom: 1px solid var(--gray-200); margin-bottom: 0; }
|
||||
.tab-btn {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-500);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.tab-btn.active { color: var(--l4-purple); border-bottom-color: var(--l4-purple); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h2>
|
||||
<i class="fa-solid fa-brain" style="color:var(--l4-purple);margin-right:8px;"></i>
|
||||
AI Cross-Analysis
|
||||
<span class="privacy-badge" style="margin-left:10px;">
|
||||
<i class="fa-solid fa-lock"></i> Dati aggregati — privacy garantita
|
||||
</span>
|
||||
</h2>
|
||||
<div class="content-header-actions">
|
||||
<span class="badge" style="background:var(--l4-purple-bg);color:var(--l4-purple);font-size:0.75rem;padding:4px 10px;border-radius:12px;">
|
||||
<i class="fa-solid fa-user-tie"></i> Solo Consulenti & Admin
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
|
||||
<!-- Portfolio header -->
|
||||
<div class="portfolio-header" id="portfolio-header">
|
||||
<div>
|
||||
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
|
||||
<div style="font-size:1rem;font-weight:600;">Caricamento...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" onclick="switchTab('chat')">
|
||||
<i class="fa-solid fa-message"></i> Chat AI
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchTab('history')">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Storico
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchTab('portfolio')">
|
||||
<i class="fa-solid fa-chart-pie"></i> Portfolio dati
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: CHAT -->
|
||||
<div class="tab-pane active" id="tab-chat">
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="chat-empty" id="chat-empty">
|
||||
<i class="fa-solid fa-brain"></i>
|
||||
<h4>Analisi AI del tuo portfolio clienti</h4>
|
||||
<p>Fai una domanda sui dati aggregati delle tue organizzazioni. I dati vengono trasmessi in forma anonima e aggregata.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<div class="quick-qs" id="quick-qs">
|
||||
<button class="quick-q" onclick="setQuestion('Quali sono i gap di compliance più comuni nel mio portfolio?')">Gap più frequenti</button>
|
||||
<button class="quick-q" onclick="setQuestion('Qual è il livello medio di rischio nelle mie organizzazioni?')">Livello di rischio medio</button>
|
||||
<button class="quick-q" onclick="setQuestion('Quali categorie NIS2 sono meno implementate?')">Categorie NIS2 deboli</button>
|
||||
<button class="quick-q" onclick="setQuestion('Quali organizzazioni hanno bisogno di intervento urgente?')">Intervento urgente</button>
|
||||
<button class="quick-q" onclick="setQuestion('Com\'è lo stato della formazione nei miei clienti?')">Stato formazione</button>
|
||||
<button class="quick-q" onclick="setQuestion('Benchmark: dove si posiziona il mio portfolio rispetto agli standard NIS2?')">Benchmark NIS2</button>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
<textarea id="chat-input" placeholder="Es: Quali sono le aree più critiche nel mio portfolio clienti?" rows="2"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea>
|
||||
<button class="btn-send" onclick="sendMessage()" id="btn-send">
|
||||
<i class="fa-solid fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div style="font-size:0.7rem;color:var(--gray-400);margin-top:6px;text-align:center;">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
Dati anonimizzati e aggregati — nessun nome organizzazione inviato all'AI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: HISTORY -->
|
||||
<div class="tab-pane" id="tab-history">
|
||||
<div style="padding:16px;" id="history-content">
|
||||
<div class="spinner" style="margin:30px auto;display:block;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: PORTFOLIO DATI -->
|
||||
<div class="tab-pane" id="tab-portfolio">
|
||||
<div style="padding:16px;" id="portfolio-content">
|
||||
<div class="spinner" style="margin:30px auto;display:block;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
|
||||
let PORTFOLIO = null; // dati aggregati portfolio
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
loadPortfolio();
|
||||
|
||||
async function loadPortfolio() {
|
||||
try {
|
||||
const r = await api.get('/cross-analysis/portfolio');
|
||||
if (!r.success) {
|
||||
if (r.error_code === 'INSUFFICIENT_ROLE') {
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div style="color:white;text-align:center;width:100%;">
|
||||
<i class="fa-solid fa-lock" style="font-size:1.5rem;opacity:0.6;"></i><br>
|
||||
<strong>Funzione riservata a Consulenti e Super Admin</strong>
|
||||
</div>`;
|
||||
document.querySelector('.card').innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--gray-500);">
|
||||
<i class="fa-solid fa-lock" style="font-size:2rem;color:var(--gray-300);"></i>
|
||||
<p style="margin-top:12px;">Hai bisogno del ruolo <strong>Consultant</strong> o <strong>Super Admin</strong> per accedere all\'analisi cross-organizzazione.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
throw new Error(r.message);
|
||||
}
|
||||
PORTFOLIO = r.data;
|
||||
renderPortfolioHeader(PORTFOLIO);
|
||||
renderPortfolioTab(PORTFOLIO);
|
||||
} catch(e) {
|
||||
console.error('Portfolio load error:', e);
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div style="color:white;opacity:0.7;">Errore caricamento portfolio</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPortfolioHeader(d) {
|
||||
const agg = d.aggregated || {};
|
||||
const score = agg.avg_compliance_score ?? '—';
|
||||
const totalRisks = agg.risks?.total_open ?? '—';
|
||||
const trainPct = agg.training?.avg_completion_rate ?? '—';
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div>
|
||||
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
|
||||
<div style="font-size:1.1rem;font-weight:700;">${d.org_count} organizzazioni monitorate</div>
|
||||
</div>
|
||||
<div class="portfolio-stat"><div class="val">${score}${score!=='—'?'%':''}</div><div class="lbl">Compliance medio</div></div>
|
||||
<div class="portfolio-stat"><div class="val">${totalRisks}</div><div class="lbl">Rischi aperti</div></div>
|
||||
<div class="portfolio-stat"><div class="val">${trainPct}${trainPct!=='—'?'%':''}</div><div class="lbl">Formazione media</div></div>
|
||||
<div class="portfolio-stat"><div class="val">${d.org_count}</div><div class="lbl">Organizzazioni</div></div>`;
|
||||
}
|
||||
|
||||
function renderPortfolioTab(d) {
|
||||
const agg = d.aggregated;
|
||||
if (!agg) {
|
||||
document.getElementById('portfolio-content').innerHTML = '<p class="text-muted">Nessun dato disponibile.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="grid-2" style="gap:16px;">';
|
||||
|
||||
// Settori
|
||||
if (agg.by_sector && Object.keys(agg.by_sector).length) {
|
||||
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Settoriale</h4></div><div class="card-body">`;
|
||||
for (const [sec, cnt] of Object.entries(agg.by_sector)) {
|
||||
const pct = Math.round(cnt / d.org_count * 100);
|
||||
html += `<div style="margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:3px;">
|
||||
<span>${sec}</span><span style="font-weight:600;">${cnt} (${pct}%)</span>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--gray-200);border-radius:2px;">
|
||||
<div style="height:100%;width:${pct}%;background:var(--l4-purple);border-radius:2px;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Compliance score
|
||||
if (agg.score_distribution) {
|
||||
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Score Compliance</h4></div><div class="card-body">`;
|
||||
const colors = {'0-20%':'var(--danger)','21-40%':'#f97316','41-60%':'var(--warning)','61-80%':'#84cc16','81-100%':'var(--secondary)'};
|
||||
for (const [range, cnt] of Object.entries(agg.score_distribution)) {
|
||||
if (cnt === 0) continue;
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:0.82rem;">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:${colors[range]||'var(--gray-400)'};flex-shrink:0;"></div>
|
||||
<span style="flex:1;">${range}</span>
|
||||
<strong>${cnt} org</strong>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Categorie NIS2 deboli
|
||||
if (agg.assessments?.avg_by_category && Object.keys(agg.assessments.avg_by_category).length) {
|
||||
const cats = Object.entries(agg.assessments.avg_by_category).slice(0, 5);
|
||||
html += `<div class="card" style="margin:0;grid-column:1/-1;"><div class="card-header"><h4>Score Medio per Categoria NIS2 (dal più basso)</h4></div><div class="card-body">`;
|
||||
html += '<div class="step-cards" style="grid-template-columns:repeat(auto-fill,minmax(180px,1fr));">';
|
||||
for (const [cat, score] of cats) {
|
||||
const color = score < 30 ? 'var(--danger)' : score < 60 ? 'var(--warning)' : 'var(--secondary)';
|
||||
html += `<div class="step-card" style="cursor:default;">
|
||||
<div style="font-size:0.75rem;color:var(--gray-500);margin-bottom:4px;">${cat}</div>
|
||||
<div style="font-size:1.4rem;font-weight:800;color:${color};">${score}%</div>
|
||||
<div style="height:3px;background:var(--gray-200);border-radius:2px;margin-top:6px;">
|
||||
<div style="height:100%;width:${score}%;background:${color};border-radius:2px;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div></div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
document.getElementById('portfolio-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
const tabs = ['chat','history','portfolio'];
|
||||
b.classList.toggle('active', tabs[i] === tab);
|
||||
});
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + tab).classList.add('active');
|
||||
if (tab === 'history') loadHistory();
|
||||
}
|
||||
|
||||
// ── Send message ──────────────────────────────────────────────────
|
||||
let isThinking = false;
|
||||
|
||||
async function sendMessage() {
|
||||
if (isThinking) return;
|
||||
const input = document.getElementById('chat-input');
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
// Rimuovi empty state
|
||||
const empty = document.getElementById('chat-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
// Mostra messaggio utente
|
||||
appendMessage('user', question);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
|
||||
// Thinking bubble
|
||||
isThinking = true;
|
||||
document.getElementById('btn-send').disabled = true;
|
||||
const thinkId = 'think-' + Date.now();
|
||||
appendThinking(thinkId);
|
||||
|
||||
try {
|
||||
const r = await api.post('/cross-analysis/analyze', { question });
|
||||
removeThinking(thinkId);
|
||||
if (r.success && r.data?.result) {
|
||||
appendAIResponse(r.data.result, r.data.org_count);
|
||||
} else {
|
||||
appendError(r.message || 'Errore sconosciuto');
|
||||
}
|
||||
} catch(e) {
|
||||
removeThinking(thinkId);
|
||||
appendError('Errore di connessione: ' + e.message);
|
||||
} finally {
|
||||
isThinking = false;
|
||||
document.getElementById('btn-send').disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(role, text) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
|
||||
const icon = role === 'user' ? 'fa-user' : 'fa-brain';
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ${role}">
|
||||
<div class="msg-avatar"><i class="fa-solid ${icon}"></i></div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble">${escapeHtml(text)}</div>
|
||||
<div class="msg-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendAIResponse(result, orgCount) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
|
||||
|
||||
let inner = `<div class="ai-answer">${escapeHtml(result.answer || 'Nessuna risposta')}</div>`;
|
||||
|
||||
if (result.key_findings?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title"><i class="fa-solid fa-magnifying-glass"></i> Risultati chiave</div>
|
||||
<ul>${result.key_findings.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.recommendations?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title"><i class="fa-solid fa-lightbulb"></i> Raccomandazioni</div>
|
||||
<ul>${result.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.risk_areas?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title"><i class="fa-solid fa-triangle-exclamation"></i> Aree di attenzione</div>
|
||||
<ul>${result.risk_areas.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.benchmark_note) {
|
||||
inner += `<div class="ai-benchmark"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(result.benchmark_note)}</div>`;
|
||||
}
|
||||
if (result.privacy_note) {
|
||||
inner += `<div class="ai-privacy-note"><i class="fa-solid fa-lock"></i> ${escapeHtml(result.privacy_note)}</div>`;
|
||||
}
|
||||
|
||||
inner += `<div style="margin-top:8px;font-size:0.68rem;color:var(--gray-400);">Basato su ${orgCount} organizzazioni anonimizzate</div>`;
|
||||
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai">
|
||||
<div class="msg-avatar"><i class="fa-solid fa-brain"></i></div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble">${inner}</div>
|
||||
<div class="msg-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendError(msg) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai">
|
||||
<div class="msg-avatar" style="background:var(--danger);"><i class="fa-solid fa-xmark"></i></div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble" style="background:var(--danger-bg);color:var(--danger);">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> ${escapeHtml(msg)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendThinking(id) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai" id="${id}">
|
||||
<div class="msg-avatar"><i class="fa-solid fa-brain"></i></div>
|
||||
<div class="msg-body">
|
||||
<div class="thinking-bubble">
|
||||
<div class="thinking-dot"></div>
|
||||
<div class="thinking-dot"></div>
|
||||
<div class="thinking-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function removeThinking(id) {
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
const el = document.getElementById('history-content');
|
||||
el.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
|
||||
try {
|
||||
const r = await api.get('/cross-analysis/history?limit=20');
|
||||
if (!r.success) { el.innerHTML = '<p class="text-muted">Errore caricamento storico.</p>'; return; }
|
||||
const rows = r.data?.history || [];
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<p class="text-muted" style="padding:20px;text-align:center;">Nessuna analisi precedente.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows.map(row => `
|
||||
<div style="border-bottom:1px solid var(--gray-100);padding:12px 0;">
|
||||
<div style="font-size:0.78rem;color:var(--gray-400);margin-bottom:4px;">
|
||||
${new Date(row.created_at).toLocaleString('it-IT')}
|
||||
</div>
|
||||
<div style="font-size:0.84rem;font-weight:600;color:var(--gray-700);margin-bottom:4px;">
|
||||
${escapeHtml((row.prompt_summary || '').replace(/^CROSS_ORG \[.*?\]: /, ''))}
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--gray-500);">${escapeHtml(row.response_summary || '—')}</div>
|
||||
</div>`).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = '<p class="text-muted">Errore: ' + escapeHtml(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
function setQuestion(q) {
|
||||
document.getElementById('chat-input').value = q;
|
||||
document.getElementById('chat-input').focus();
|
||||
autoResizeTextarea(document.getElementById('chat-input'));
|
||||
}
|
||||
|
||||
function autoResizeTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
||||
}
|
||||
|
||||
document.getElementById('chat-input').addEventListener('input', function() {
|
||||
autoResizeTextarea(this);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -102,7 +102,8 @@ $controllerMap = [
|
||||
'invites' => 'InviteController',
|
||||
'webhooks' => 'WebhookController',
|
||||
'whistleblowing'=> 'WhistleblowingController',
|
||||
'normative' => 'NormativeController',
|
||||
'normative' => 'NormativeController',
|
||||
'cross-analysis' => 'CrossAnalysisController',
|
||||
];
|
||||
|
||||
if (!isset($controllerMap[$controllerName])) {
|
||||
@ -361,6 +362,13 @@ $actionMap = [
|
||||
'GET:stats' => 'stats',
|
||||
'POST:create' => 'create',
|
||||
],
|
||||
|
||||
// ── CrossAnalysisController (L4 AI cross-org) ──
|
||||
'cross-analysis' => [
|
||||
'POST:analyze' => 'analyze',
|
||||
'GET:history' => 'history',
|
||||
'GET:portfolio' => 'portfolio',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -199,6 +199,7 @@ function loadSidebar() {
|
||||
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' },
|
||||
{ name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
|
||||
{ name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
|
||||
{ name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user